@bevel-software/platform-shared 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,20 +3,29 @@
3
3
  * their user-visible display names. Single source of truth for BOTH sides of
4
4
  * the app, so backend enforcement and frontend affordances can never drift.
5
5
  *
6
- * CONFIGURED, NOT IMPORTED. This module used to read `process.env` at import
7
- * time and throw when it found nothing — which made the branch model something
8
- * a deployment had to know before any code could load. On the frontend that
9
- * meant baking it into the bundle at build time (Vite `define`), so the same
10
- * artifact could not serve two deployments and changing a branch name meant a
11
- * rebuild. Both sides now CALL {@link configureBranchModel} during boot:
12
- * the backend from its environment, the browser from `GET /api/config`.
6
+ * TWO SHAPES, ONE RULE SET.
13
7
  *
14
- * The exported bindings stay bindings — `DEFAULT_BRANCH` is still imported and
15
- * read exactly as before at every call site. ES module live bindings mean a
16
- * reader inside a function body sees whatever configuration has been applied by
17
- * the time it runs. What does NOT work is capturing one at module scope
18
- * (`const X = DEFAULT_BRANCH` in a file's top level), which snapshots the value
19
- * at import — before configuration. Use a function there instead.
8
+ * A VALUE ({@link BranchModelValue}), resolved by {@link resolveBranchModel}
9
+ * from the shape `/api/config` serves and the setup screen collects. The pure
10
+ * helpers below (`isProtectedBranch`, `protectedBranchDisplayName`) take the
11
+ * value they judge against, so a caller holding two deployments' models — a
12
+ * server hosting several knowledge bases in one process — asks about each by
13
+ * name. The backend uses ONLY this shape: every service is handed its
14
+ * knowledge base's model at construction and never reads a process-wide one.
15
+ *
16
+ * The BROWSER'S live bindings (`DEFAULT_BRANCH`, `PROTECTED_BRANCHES`, …),
17
+ * applied once by {@link configureBranchModel} from `GET /api/config` before
18
+ * React renders. A page shows one deployment, so one process-wide value is
19
+ * the right shape there, and the bindings let a component read the model
20
+ * without threading it through props. They are for the browser: the backend
21
+ * package forbids importing them (an ESLint rule names each one), and a
22
+ * server that configured them would be setting a value for every knowledge
23
+ * base it serves at once.
24
+ *
25
+ * Reading a live binding works inside a function body, which sees whatever
26
+ * configuration has been applied by the time it runs. What does NOT work is
27
+ * capturing one at module scope (`const X = DEFAULT_BRANCH` in a file's top
28
+ * level), which snapshots the value at import — before configuration.
20
29
  */
21
30
 
22
31
  declare const process: { env: Record<string, string | undefined> };
@@ -39,26 +48,66 @@ function deriveDisplayName(slug: string): string {
39
48
  return spaced.charAt(0).toUpperCase() + spaced.slice(1);
40
49
  }
41
50
 
51
+ /** The shape both sides configure from, and the shape `/api/config` serves. */
52
+ export interface BranchModel {
53
+ defaultBranch: string;
54
+ /** Slugs; a comma/space-separated string is accepted for env convenience. */
55
+ protectedBranches: string[] | string;
56
+ }
57
+
58
+ /**
59
+ * A branch model as a VALUE: the resolved, validated form of {@link BranchModel}
60
+ * that everything judging a branch is handed.
61
+ *
62
+ * `defaultBranch` is the branch a logged-in user lands on when they don't
63
+ * explicitly pick one, and the default destination for shared drafts. It is
64
+ * the empty string on the one model that is not configured yet
65
+ * ({@link UNCONFIGURED_BRANCH_MODEL}): a fresh deployment has no branch model
66
+ * until its setup screen is answered, and everything behind that gate reads
67
+ * the model only once it is.
68
+ */
69
+ export interface BranchModelValue {
70
+ readonly defaultBranch: string;
71
+ readonly protectedBranches: ReadonlySet<string>;
72
+ /** Slug → human-readable name, for every protected branch. */
73
+ readonly displayNames: Readonly<Record<string, string>>;
74
+ }
75
+
76
+ /**
77
+ * The model a deployment has before setup has answered for one: no default
78
+ * branch, nothing protected. {@link isBranchModelConfigured} is false for
79
+ * exactly this value.
80
+ */
81
+ export const UNCONFIGURED_BRANCH_MODEL: BranchModelValue = Object.freeze({
82
+ defaultBranch: '',
83
+ protectedBranches: new Set<string>(),
84
+ displayNames: Object.freeze({}),
85
+ });
86
+
87
+ /**
88
+ * Whether a model has been configured — the frontend gates render on this, and
89
+ * the backend's setup gate keeps the app shut until it holds.
90
+ */
91
+ export function isBranchModelConfigured(model: BranchModelValue): boolean {
92
+ return model.defaultBranch !== '';
93
+ }
94
+
42
95
  /**
43
96
  * The branch a logged-in user lands on when they don't explicitly pick one, and
44
- * the default destination for shared drafts.
97
+ * the default destination for shared drafts. BROWSER-SIDE live binding — see
98
+ * this file's header; the backend reads its `BranchModelValue` instead.
45
99
  *
46
100
  * Empty until {@link configureBranchModel} runs. Read it inside a function, not
47
- * at module scope — see this file's header.
101
+ * at module scope.
48
102
  */
49
103
  export let DEFAULT_BRANCH: string = '';
50
104
 
105
+ /** Browser-side live binding — see this file's header. */
51
106
  export let PROTECTED_BRANCH_DISPLAY_NAMES: Readonly<Record<string, string>> = Object.freeze({});
52
107
 
108
+ /** Browser-side live binding — see this file's header. */
53
109
  export let PROTECTED_BRANCHES: ReadonlySet<string> = new Set<string>();
54
110
 
55
- /** The shape both sides configure from, and the shape `/api/config` serves. */
56
- export interface BranchModel {
57
- defaultBranch: string;
58
- /** Slugs; a comma/space-separated string is accepted for env convenience. */
59
- protectedBranches: string[] | string;
60
- }
61
-
62
111
  /**
63
112
  * Apply the branch model. Called once during boot on each side, and validated
64
113
  * here rather than at either call site so the two cannot disagree about what
@@ -96,22 +145,46 @@ export function validateBranchModel(model: BranchModel): string | null {
96
145
  return null;
97
146
  }
98
147
 
99
- export function configureBranchModel(model: BranchModel): void {
148
+ /**
149
+ * Resolve a model into the value everything judges against. Throws on an
150
+ * invalid pair — the same rule {@link validateBranchModel} states — so a
151
+ * value, once held, is known to be a usable one.
152
+ */
153
+ export function resolveBranchModel(model: BranchModel): BranchModelValue {
100
154
  const problem = validateBranchModel(model);
101
155
  if (problem) throw new Error(problem);
102
- const defaultBranch = model.defaultBranch.trim();
103
156
  const list = branchListOf(model);
157
+ return Object.freeze({
158
+ defaultBranch: model.defaultBranch.trim(),
159
+ protectedBranches: new Set(list),
160
+ displayNames: Object.freeze(Object.fromEntries(list.map((slug) => [slug, deriveDisplayName(slug)]))),
161
+ });
162
+ }
104
163
 
105
- DEFAULT_BRANCH = defaultBranch;
106
- PROTECTED_BRANCH_DISPLAY_NAMES = Object.freeze(
107
- Object.fromEntries(list.map((slug) => [slug, deriveDisplayName(slug)])),
108
- );
109
- PROTECTED_BRANCHES = new Set(list);
164
+ /**
165
+ * Apply the model to the BROWSER'S live bindings. Called once during boot,
166
+ * from `GET /api/config`, before anything reads them. Validated by
167
+ * {@link resolveBranchModel}, so the bindings and a value resolved from the
168
+ * same model can never disagree.
169
+ */
170
+ export function configureBranchModel(model: BranchModel): void {
171
+ const value = resolveBranchModel(model);
172
+ DEFAULT_BRANCH = value.defaultBranch;
173
+ PROTECTED_BRANCH_DISPLAY_NAMES = value.displayNames;
174
+ PROTECTED_BRANCHES = value.protectedBranches;
110
175
  }
111
176
 
112
- /** Whether {@link configureBranchModel} has run — the frontend gates render on this. */
113
- export function isBranchModelConfigured(): boolean {
114
- return DEFAULT_BRANCH !== '';
177
+ /**
178
+ * The browser's model as a value — for the code paths that take a
179
+ * {@link BranchModelValue} and run in the browser, where the one model in
180
+ * effect is the configured one.
181
+ */
182
+ export function currentBranchModel(): BranchModelValue {
183
+ return {
184
+ defaultBranch: DEFAULT_BRANCH,
185
+ protectedBranches: PROTECTED_BRANCHES,
186
+ displayNames: PROTECTED_BRANCH_DISPLAY_NAMES,
187
+ };
115
188
  }
116
189
 
117
190
  /**
@@ -125,11 +198,9 @@ export function branchModelFromEnv(): BranchModel {
125
198
  };
126
199
  }
127
200
 
128
- export function isProtectedBranch(name: string | null | undefined): boolean {
129
- return !!name && Object.prototype.hasOwnProperty.call(
130
- PROTECTED_BRANCH_DISPLAY_NAMES,
131
- name,
132
- );
201
+ /** Whether `name` is one of `model`'s protected branches. */
202
+ export function isProtectedBranch(model: BranchModelValue, name: string | null | undefined): boolean {
203
+ return !!name && model.protectedBranches.has(name);
133
204
  }
134
205
 
135
206
  /**
@@ -145,8 +216,11 @@ export function isProtectedBranch(name: string | null | undefined): boolean {
145
216
  * whether to fall back to the raw string or hide the affordance.
146
217
  */
147
218
  export function protectedBranchDisplayName(
219
+ model: BranchModelValue,
148
220
  name: string | null | undefined,
149
221
  ): string | null {
150
222
  if (!name) return null;
151
- return PROTECTED_BRANCH_DISPLAY_NAMES[name] ?? null;
223
+ return Object.prototype.hasOwnProperty.call(model.displayNames, name)
224
+ ? model.displayNames[name]!
225
+ : null;
152
226
  }
@@ -36,12 +36,19 @@ import { validateFilename } from './filename.js';
36
36
  * Don't hard-code these strings elsewhere — import them from here.
37
37
  *
38
38
  * CONFIGURABLE, WITH DEFAULTS. The three roots a deployment may rename
39
- * (`KnowledgeBase/`, `Skills/`, `Plugins/`) are `let` bindings applied by
40
- * {@link configureKbLayout} — the backend from its deployment settings, the
41
- * browser from `GET /api/config` — the same live-binding pattern as the branch
42
- * model in `git/protected.ts`. Unlike the branch model they carry defaults, so
43
- * nothing has to wait for configuration; but the same rule applies: read them
44
- * inside a function body, never capture one at module scope.
39
+ * (`KnowledgeBase/`, `Skills/`, `Plugins/`) and the guide's file name travel
40
+ * as a {@link KbLayout} VALUE: every helper below that depends on a name takes
41
+ * the layout it judges against, and the backend hands each knowledge base's
42
+ * layout to its services at construction — a server hosting several knowledge
43
+ * bases in one process holds one per knowledge base, never a process-wide one.
44
+ *
45
+ * The `let` bindings (`KNOWLEDGE_BASE_DIR`, …) are the BROWSER'S copy, applied
46
+ * once by {@link configureKbLayout} from `GET /api/config`, the same shape as
47
+ * the branch model in `git/protected.ts`: a page shows one deployment, so one
48
+ * process-wide value is right there. The backend package forbids importing
49
+ * them (an ESLint rule names each one). They carry defaults, so nothing waits
50
+ * for configuration; but read them inside a function body, never capture one
51
+ * at module scope.
45
52
  */
46
53
 
47
54
  /** Folder under the repo root that contains all team ontologies. */
@@ -218,7 +225,7 @@ export function gitignoreLiteral(name: string): string {
218
225
  * whether this sentence is present by searching for the RAW name — see
219
226
  * {@link mentionsAgentsFile}, which is how the startup step asks.
220
227
  */
221
- export function agentsFilePointerSentence(agentsFile: string = AGENTS_FILE): string {
228
+ export function agentsFilePointerSentence(agentsFile: string): string {
222
229
  return `Read [${markdownLinkLabel(agentsFile)}](${agentsFileLinkPath(agentsFile)})${POINTER_SENTENCE_TAIL}`;
223
230
  }
224
231
 
@@ -266,7 +273,7 @@ function escapeRegExp(text: string): string {
266
273
  * answers, and only the caller knows that the second one means "consider
267
274
  * appending".
268
275
  */
269
- export function retargetAgentsFilePointer(text: string, agentsFile: string = AGENTS_FILE): string | null {
276
+ export function retargetAgentsFilePointer(text: string, agentsFile: string): string | null {
270
277
  let found = false;
271
278
  const wanted = agentsFilePointerSentence(agentsFile);
272
279
  const updated = text.replace(POINTER_SENTENCE_PATTERN, () => {
@@ -315,7 +322,7 @@ function agentsFileLinkPath(agentsFile: string): string {
315
322
  * that one would append a second copy on the next boot, and a third on the
316
323
  * one after — the exact failure this feature exists to prevent.
317
324
  */
318
- export function mentionsAgentsFile(text: string, agentsFile: string = AGENTS_FILE): boolean {
325
+ export function mentionsAgentsFile(text: string, agentsFile: string): boolean {
319
326
  return (
320
327
  text.includes(agentsFile) ||
321
328
  text.includes(markdownLinkLabel(agentsFile)) ||
@@ -369,7 +376,7 @@ export function validateKbRootName(name: string): string | null {
369
376
  */
370
377
  export function validateAgentsFileName(
371
378
  name: string,
372
- roots: Pick<KbLayout, 'knowledgeBaseDir' | 'skillsDir' | 'pluginsDir'> = currentKbLayout(),
379
+ roots: Pick<KbLayout, 'knowledgeBaseDir' | 'skillsDir' | 'pluginsDir'>,
373
380
  ): string | null {
374
381
  const v = name.trim();
375
382
  if (!v) return 'A file name is required.';
@@ -466,21 +473,37 @@ export function onKbLayoutApplied(listener: () => void): void {
466
473
  }
467
474
 
468
475
  /**
469
- * Apply the layout. Called once during boot on each side; throws on an invalid
470
- * one so a bad deployment setting fails beside the rest of the wiring rather
471
- * than scattering a half-renamed tree. Applying the defaults is a no-op.
476
+ * A layout as the helpers hold it: validated, every name trimmed, the guide's
477
+ * name filled in from the default when absent. Throws on an invalid one so a
478
+ * bad deployment setting fails beside the rest of the wiring rather than
479
+ * scattering a half-renamed tree.
472
480
  */
473
- export function configureKbLayout(layout: KbLayout): void {
481
+ export function resolveKbLayout(layout: KbLayout): Required<KbLayout> {
474
482
  const problem = validateKbLayout(layout);
475
483
  if (problem) throw new Error(problem);
476
- KNOWLEDGE_BASE_DIR = layout.knowledgeBaseDir.trim();
477
- SKILLS_DIR = layout.skillsDir.trim();
478
- PLUGINS_DIR = layout.pluginsDir.trim();
479
- AGENTS_FILE = agentsFileOf(layout);
484
+ return Object.freeze({
485
+ knowledgeBaseDir: layout.knowledgeBaseDir.trim(),
486
+ skillsDir: layout.skillsDir.trim(),
487
+ pluginsDir: layout.pluginsDir.trim(),
488
+ agentsFile: agentsFileOf(layout),
489
+ });
490
+ }
491
+
492
+ /**
493
+ * Apply the layout to the BROWSER'S live bindings. Called once during boot
494
+ * from `GET /api/config`; validated by {@link resolveKbLayout}. Applying the
495
+ * defaults is a no-op.
496
+ */
497
+ export function configureKbLayout(layout: KbLayout): void {
498
+ const resolved = resolveKbLayout(layout);
499
+ KNOWLEDGE_BASE_DIR = resolved.knowledgeBaseDir;
500
+ SKILLS_DIR = resolved.skillsDir;
501
+ PLUGINS_DIR = resolved.pluginsDir;
502
+ AGENTS_FILE = resolved.agentsFile;
480
503
  for (const listener of layoutListeners) listener();
481
504
  }
482
505
 
483
- /** The layout currently in effect. */
506
+ /** The browser's layout as a value — what the live bindings currently hold. */
484
507
  export function currentKbLayout(): Required<KbLayout> {
485
508
  return {
486
509
  knowledgeBaseDir: KNOWLEDGE_BASE_DIR,
@@ -491,14 +514,14 @@ export function currentKbLayout(): Required<KbLayout> {
491
514
  }
492
515
 
493
516
  /**
494
- * Whether a layout — the one in effect, unless one is given — is the default
495
- * one. The setup-completing save applies the stored names while this holds,
496
- * the same "only from none to some" rule the branch model follows — and also
497
- * on a retry after its own failed initialization run, when the process holds
498
- * names setup applied but the app never opened (see `setup.routes.ts`). A
499
- * layout the process booted with is never replaced here.
517
+ * Whether a layout is the default one. The setup-completing save applies the
518
+ * stored names while this holds of the one in effect, the same "only from
519
+ * none to some" rule the branch model follows — and also on a retry after its
520
+ * own failed initialization run, when the process holds names setup applied
521
+ * but the app never opened (see `setup.routes.ts`). A layout the process
522
+ * booted with is never replaced there.
500
523
  */
501
- export function isDefaultKbLayout(layout: KbLayout = currentKbLayout()): boolean {
524
+ export function isDefaultKbLayout(layout: KbLayout): boolean {
502
525
  return (
503
526
  layout.knowledgeBaseDir === DEFAULT_KB_LAYOUT.knowledgeBaseDir &&
504
527
  layout.skillsDir === DEFAULT_KB_LAYOUT.skillsDir &&
@@ -516,7 +539,7 @@ export function isDefaultKbLayout(layout: KbLayout = currentKbLayout()): boolean
516
539
  * renamed the guide gets a guide naming the file it lives in. Text without
517
540
  * placeholders passes through unchanged.
518
541
  */
519
- export function renderKbLayoutPlaceholders(text: string, layout: KbLayout = currentKbLayout()): string {
542
+ export function renderKbLayoutPlaceholders(text: string, layout: KbLayout): string {
520
543
  // Replacer FUNCTIONS: a string replacement would interpret `$&`, `$$` and
521
544
  // friends inside a folder name, and `$` is a legal character in one.
522
545
  return text
@@ -790,9 +813,9 @@ export function isPersonalPluginFolder(folderName: string): boolean {
790
813
  * to start with the prefix is a plugin — discovery, the principal picker and
791
814
  * the item pages all ask this one question of the FOLDER.
792
815
  */
793
- export function isPersonalPluginDir(repoRelDir: string): boolean {
816
+ export function isPersonalPluginDir(repoRelDir: string, layout: KbLayout): boolean {
794
817
  const segments = repoRelDir.split('/').filter(Boolean);
795
- return segments.length === 2 && segments[0] === PLUGINS_DIR && isPersonalPluginFolder(segments[1]!);
818
+ return segments.length === 2 && segments[0] === layout.pluginsDir && isPersonalPluginFolder(segments[1]!);
796
819
  }
797
820
 
798
821
  /**
@@ -808,9 +831,9 @@ export function isPersonalPluginDir(repoRelDir: string): boolean {
808
831
  * have. Callers bucket by it; nothing requires it. A plugin-less skill is a
809
832
  * real, supported state — the prototype calls those "yours alone".
810
833
  */
811
- export function pluginOfPath(repoRelativePath: string): string | null {
834
+ export function pluginOfPath(repoRelativePath: string, layout: KbLayout): string | null {
812
835
  const segments = repoRelativePath.split('/').filter(Boolean);
813
- if (segments[0] !== PLUGINS_DIR) return null;
836
+ if (segments[0] !== layout.pluginsDir) return null;
814
837
  // Needs a segment for the plugin AND at least one below it, otherwise
815
838
  // `Plugins/GTM` (the folder itself) would report itself as being in a plugin,
816
839
  // and a loose `Plugins/slack.tool` would report a plugin named "slack.tool".
@@ -833,19 +856,19 @@ export const PIPELINES_DIR = 'Pipelines';
833
856
  /**
834
857
  * The roots whose subfolders are discovered as ontologies by the graph parser
835
858
  * (each subfolder with both `NodeTypes/` and `Knowledge/` is an ontology).
836
- * A function, not a constant: `KNOWLEDGE_BASE_DIR` is configurable, and a
837
- * module-scope array would snapshot the default before configuration.
859
+ * A function of the layout, not a constant: the knowledge-base root is
860
+ * configurable per deployment.
838
861
  */
839
- export function ontologyRoots(): readonly string[] {
840
- return [KNOWLEDGE_BASE_DIR, DATA_DIR];
862
+ export function ontologyRoots(layout: KbLayout): readonly string[] {
863
+ return [layout.knowledgeBaseDir, DATA_DIR];
841
864
  }
842
865
 
843
866
  /**
844
- * Every reserved root name, as currently configured — the set the file tree
845
- * renders as its own sections rather than folding into Knowledge.
867
+ * Every reserved root name under `layout` — the set the file tree renders as
868
+ * its own sections rather than folding into Knowledge.
846
869
  */
847
- export function reservedRootDirNames(): ReadonlySet<string> {
848
- return new Set([KNOWLEDGE_BASE_DIR, SKILLS_DIR, PLUGINS_DIR, DATA_DIR, AGENTS_DIR, PIPELINES_DIR]);
870
+ export function reservedRootDirNames(layout: KbLayout): ReadonlySet<string> {
871
+ return new Set([layout.knowledgeBaseDir, layout.skillsDir, layout.pluginsDir, DATA_DIR, AGENTS_DIR, PIPELINES_DIR]);
849
872
  }
850
873
 
851
874
  /**
@@ -854,11 +877,11 @@ export function reservedRootDirNames(): ReadonlySet<string> {
854
877
  * needs read access to where it lands (the "read before write" rule); a new
855
878
  * folder directly under one of these three is the one place that rule does
856
879
  * not apply, because the new folder carries its creator's own grant. A
857
- * function, like {@link reservedRootDirNames}, because the names are
858
- * configurable.
880
+ * function of the layout, like {@link reservedRootDirNames}, because the
881
+ * names are configurable.
859
882
  */
860
- export function creatableRootDirNames(): ReadonlySet<string> {
861
- return new Set([KNOWLEDGE_BASE_DIR, SKILLS_DIR, PLUGINS_DIR]);
883
+ export function creatableRootDirNames(layout: KbLayout): ReadonlySet<string> {
884
+ return new Set([layout.knowledgeBaseDir, layout.skillsDir, layout.pluginsDir]);
862
885
  }
863
886
 
864
887
  /** The `Knowledge/` marker subfolder of an ontology (holds the graph nodes). */
@@ -2,7 +2,6 @@ import {
2
2
  DEFAULT_KB_LAYOUT,
3
3
  FIXED_PLATFORM_FILE_NAMES,
4
4
  agentsFileOf,
5
- currentKbLayout,
6
5
  reservedRootDirNames,
7
6
  type KbLayout,
8
7
  } from './kb-layout.js';
@@ -14,14 +13,15 @@ import {
14
13
  * the repository root only, so a nested file of either name is ordinary
15
14
  * content. Moving one changes what the platform enforces, so moves refuse them.
16
15
  *
17
- * A FUNCTION, not a constant, and that is the whole of the configurable-guide
18
- * change on this side: the guide's name is a deployment setting, so the fourth
19
- * platform file is `HEXIS.md` on one deployment and `AGENTS.md` on the next —
20
- * and on the first, a root `AGENTS.md` is the CUSTOMER'S own conventions file,
21
- * which has to move and delete like any page. Every gate asks this rather than
22
- * reading a list captured at module load.
16
+ * A FUNCTION OF THE LAYOUT, not a constant, and that is the whole of the
17
+ * configurable-guide change on this side: the guide's name is a deployment
18
+ * setting, so the fourth platform file is `HEXIS.md` on one deployment and
19
+ * `AGENTS.md` on the next — and on the first, a root `AGENTS.md` is the
20
+ * CUSTOMER'S own conventions file, which has to move and delete like any page.
21
+ * Every gate asks this with the layout it serves rather than reading a list
22
+ * captured at module load.
23
23
  */
24
- export function platformFileNames(layout: KbLayout = currentKbLayout()): readonly string[] {
24
+ export function platformFileNames(layout: KbLayout): readonly string[] {
25
25
  return [...FIXED_PLATFORM_FILE_NAMES, agentsFileOf(layout)];
26
26
  }
27
27
 
@@ -38,8 +38,8 @@ export const PLATFORM_FILE_NAMES: readonly string[] = Object.freeze(
38
38
  /** The platform files that are read wherever they sit, not only at the root. */
39
39
  const PLATFORM_FILES_AT_ANY_DEPTH = new Set(['access.md', '.bevelignore']);
40
40
 
41
- /** The names in effect, as a set — rebuilt per call, because the guide's is configurable. */
42
- const platformFiles = (): ReadonlySet<string> => new Set(platformFileNames());
41
+ /** The names under `layout`, as a set — rebuilt per call, because the guide's is configurable. */
42
+ const platformFiles = (layout: KbLayout): ReadonlySet<string> => new Set(platformFileNames(layout));
43
43
 
44
44
  const normalize = (path: string): string => path.replace(/^\.?\/+/, '').replace(/\/+$/, '');
45
45
 
@@ -65,10 +65,10 @@ const hasTraversal = (path: string): boolean =>
65
65
  * Exact spelling, as the platform reads it: `Access.md` is content. A caller
66
66
  * on a case-insensitive disk passes the path's on-disk spelling.
67
67
  */
68
- export function isPlatformFile(repoRelativePath: string): boolean {
68
+ export function isPlatformFile(repoRelativePath: string, layout: KbLayout): boolean {
69
69
  const norm = normalize(repoRelativePath);
70
70
  const name = baseName(norm);
71
- if (!platformFiles().has(name)) return false;
71
+ if (!platformFiles(layout).has(name)) return false;
72
72
  return PLATFORM_FILES_AT_ANY_DEPTH.has(name) || norm === name;
73
73
  }
74
74
 
@@ -96,10 +96,10 @@ export function platformFileCreationRefusal(pathOrName: string): string {
96
96
  * one takes a whole section of the knowledge base with it. Exact spelling; a
97
97
  * caller on a case-insensitive disk passes the path's on-disk spelling.
98
98
  */
99
- export function isPlatformFolder(repoRelativeDir: string): boolean {
99
+ export function isPlatformFolder(repoRelativeDir: string, layout: KbLayout): boolean {
100
100
  const norm = normalize(repoRelativeDir);
101
101
  if (norm === '') return true;
102
- return !norm.includes('/') && reservedRootDirNames().has(norm);
102
+ return !norm.includes('/') && reservedRootDirNames(layout).has(norm);
103
103
  }
104
104
 
105
105
  /** The sentence a delete or move of a platform folder is refused with. */
@@ -118,8 +118,8 @@ export function platformFolderRefusal(repoRelativeDir: string): string {
118
118
  * of the root's rather than standing in for it, which is why moving the
119
119
  * ROOT's copy into a folder is a move out and not a restore.
120
120
  */
121
- export function isRootPlatformFile(repoRelativePath: string): boolean {
122
- return platformFiles().has(normalize(repoRelativePath));
121
+ export function isRootPlatformFile(repoRelativePath: string, layout: KbLayout): boolean {
122
+ return platformFiles(layout).has(normalize(repoRelativePath));
123
123
  }
124
124
 
125
125
  /**
@@ -143,11 +143,12 @@ export type PlatformRestoreDestination =
143
143
 
144
144
  export function platformRestoreDestination(
145
145
  repoRelativeDestination: string,
146
+ layout: KbLayout,
146
147
  ): PlatformRestoreDestination | null {
147
148
  const norm = normalize(repoRelativeDestination);
148
149
  if (hasTraversal(norm)) return null;
149
150
  const name = baseName(norm);
150
- if (!platformFiles().has(name)) return null;
151
+ if (!platformFiles(layout).has(name)) return null;
151
152
  if (name === 'access.md') {
152
153
  const slash = norm.lastIndexOf('/');
153
154
  return { name, kind: 'folder-without-access-md', dir: slash === -1 ? '' : norm.slice(0, slash) };
@@ -178,11 +179,12 @@ export function platformRestoreDestination(
178
179
  export function isPlatformRestoreShape(
179
180
  repoRelativeSource: string,
180
181
  repoRelativeDestination: string,
182
+ layout: KbLayout,
181
183
  ): boolean {
182
184
  if (hasTraversal(repoRelativeSource)) return false;
183
185
  const name = baseName(repoRelativeSource);
184
- if (!platformFiles().has(name)) return false;
185
- if (isRootPlatformFile(repoRelativeSource)) return false;
186
- const target = platformRestoreDestination(repoRelativeDestination);
186
+ if (!platformFiles(layout).has(name)) return false;
187
+ if (isRootPlatformFile(repoRelativeSource, layout)) return false;
188
+ const target = platformRestoreDestination(repoRelativeDestination, layout);
187
189
  return target !== null && target.name === name;
188
190
  }