agent-doc-system 1.2.0 → 1.4.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +188 -1
  2. package/README.md +1 -1
  3. package/adapters/cache.mjs +5 -4
  4. package/adapters/database.mjs +155 -6
  5. package/adapters/deploy-config.mjs +3 -2
  6. package/adapters/dockerfile.mjs +3 -2
  7. package/adapters/generic.mjs +2 -1
  8. package/adapters/github-actions.mjs +2 -1
  9. package/adapters/go.mjs +4 -3
  10. package/adapters/node.mjs +3 -2
  11. package/adapters/rust.mjs +3 -0
  12. package/adapters/turborepo.mjs +3 -2
  13. package/adapters/typescript.mjs +7 -5
  14. package/adapters/wrangler.mjs +3 -2
  15. package/bin/agentdoc.mjs +9 -2
  16. package/bin/usage.txt +2 -0
  17. package/core/audit.mjs +7 -5
  18. package/core/codes.mjs +2 -0
  19. package/core/compile.mjs +12 -6
  20. package/core/derive.mjs +4 -3
  21. package/core/descriptors.mjs +52 -2
  22. package/core/discover.mjs +3 -2
  23. package/core/fsx.mjs +174 -42
  24. package/core/graph.mjs +1 -1
  25. package/core/impact.mjs +84 -14
  26. package/core/paths.mjs +71 -0
  27. package/core/query.mjs +58 -23
  28. package/core/scaffold.mjs +130 -23
  29. package/core/sourcescan.mjs +4 -1
  30. package/docs/ADAPTERS.md +49 -5
  31. package/docs/BENCHMARK.md +83 -21
  32. package/docs/CI.md +9 -1
  33. package/docs/EVALS.md +209 -17
  34. package/docs/MIGRATION.md +22 -4
  35. package/docs/PROVENANCE.md +6 -0
  36. package/docs/QUERY.md +50 -7
  37. package/docs/SPEC.md +89 -2
  38. package/docs/ci/github-actions.yml +1 -1
  39. package/evals/baseline-search.mjs +272 -0
  40. package/evals/facts.mjs +28 -0
  41. package/evals/harness.mjs +120 -116
  42. package/package.json +4 -2
  43. package/schemas/resource.schema.json +9 -1
  44. package/skills/agent-doc-system/SKILL.md +2 -1
  45. package/skills/agent-doc-system/references/cli.md +6 -1
  46. package/skills/agent-doc-system/references/create-migrate.md +8 -2
  47. package/templates/ci.yml +1 -1
@@ -6,6 +6,7 @@
6
6
  import path from "node:path";
7
7
  import { Adapter } from "./registry.mjs";
8
8
  import { parseYamlDocuments } from "../core/yaml.mjs";
9
+ import { joinPath } from "../core/paths.mjs";
9
10
 
10
11
  export class TypeScriptAdapter extends Adapter {
11
12
  static adapterName = "typescript";
@@ -13,13 +14,13 @@ export class TypeScriptAdapter extends Adapter {
13
14
  super({ name: "typescript", version: "1.0.0", kind: "unit-marker" });
14
15
  }
15
16
  detect(ctx, root) {
16
- const p = root + "/tsconfig.json";
17
+ const p = joinPath(root, "tsconfig.json");
17
18
  if (!ctx.repo.exists(p)) return null;
18
19
  return { path: p, shape: "importable" };
19
20
  }
20
21
  extract(ctx, unit) {
21
22
  const { repo, prov, compRef } = ctx;
22
- const p = unit.root + "/tsconfig.json";
23
+ const p = joinPath(unit.root, "tsconfig.json");
23
24
  if (!repo.exists(p)) return {};
24
25
  const derived = { languages: new Set(["typescript"]), runtimes: new Set(["node"]) };
25
26
  let ts;
@@ -43,8 +44,9 @@ export class TypeScriptAdapter extends Adapter {
43
44
  // real HTTP surface and must be visible to the query surface.
44
45
  const routes = [];
45
46
  for (const base of ["src/app", "app", "src/pages", "pages"]) {
46
- if (!repo.isDir(unit.root + "/" + base)) continue;
47
- for (const f of repo.walkUnit(unit.root, unit.root + "/" + base)) {
47
+ const dir = joinPath(unit.root, base);
48
+ if (!repo.isDir(dir)) continue;
49
+ for (const f of repo.walkUnit(unit.root, dir)) {
48
50
  const routePath = routeFor(unit.root, base, f);
49
51
  if (!routePath) continue;
50
52
  const rpr = prov.add("observed", f, "route-extractor", ["der:" + compRef + ":/routes"]);
@@ -63,7 +65,7 @@ export class TypeScriptAdapter extends Adapter {
63
65
  }
64
66
 
65
67
  function routeFor(root, base, file) {
66
- const rel = file.slice((root + "/" + base).length + 1);
68
+ const rel = file.slice(joinPath(root, base).length + 1);
67
69
  if (base.endsWith("app")) {
68
70
  if (!rel.endsWith("/route.ts") && !rel.endsWith("/route.tsx") && !rel.endsWith("/route.js")) return null;
69
71
  const segs = rel.split("/").slice(0, -1);
@@ -11,6 +11,7 @@
11
11
  import { Adapter } from "./registry.mjs";
12
12
  import { parse as parseToml } from "../core/toml.mjs";
13
13
  import { sourceFiles } from "../core/sourcescan.mjs";
14
+ import { joinPath } from "../core/paths.mjs";
14
15
 
15
16
  const MANIFESTS = ["wrangler.toml", "wrangler.json", "wrangler.jsonc"];
16
17
 
@@ -21,13 +22,13 @@ export class WranglerAdapter extends Adapter {
21
22
  }
22
23
  detect(ctx, root) {
23
24
  for (const n of MANIFESTS) {
24
- if (ctx.repo.exists(root + "/" + n)) return { path: root + "/" + n, shape: "deployable" };
25
+ if (ctx.repo.exists(joinPath(root, n))) return { path: joinPath(root, n), shape: "deployable" };
25
26
  }
26
27
  return null;
27
28
  }
28
29
  extract(ctx, unit) {
29
30
  const { repo, prov, compRef } = ctx;
30
- const file = MANIFESTS.map((n) => unit.root + "/" + n).find((p) => repo.exists(p));
31
+ const file = MANIFESTS.map((n) => joinPath(unit.root, n)).find((p) => repo.exists(p));
31
32
  if (!file) return {};
32
33
  let toml;
33
34
  try {
package/bin/agentdoc.mjs CHANGED
@@ -19,7 +19,7 @@ import { observationFreshness, stalenessDiagnostics } from "../core/observations
19
19
  import { assertNoSecretsInText } from "../core/secrets.mjs";
20
20
  import { installTemplates } from "../core/scaffold.mjs";
21
21
  import { COMMANDS, flagsOf, parseArgs, commandHelp, UsageError } from "./commands.mjs";
22
- import { loadSchemaBundle, loadConfig } from "../core/descriptors.mjs";
22
+ import { loadSchemaBundle, loadConfig, CONFIG_PATH } from "../core/descriptors.mjs";
23
23
  import { execFileSync } from "node:child_process";
24
24
 
25
25
  const HERE = path.dirname(fileURLToPath(import.meta.url));
@@ -353,7 +353,14 @@ try {
353
353
  : {}),
354
354
  }, true);
355
355
  } else if (cmd === "init") {
356
- const result = installTemplates(null, null, { write: true, root: ROOT, force: opt("force") });
356
+ // An existing configuration is the authority on where documentation lives
357
+ // (issue #49). Load it through the compiler's own loader, so init, scaffold,
358
+ // audit and compile cannot disagree, and so an invalid or escaping docs path
359
+ // is rejected before anything is written.
360
+ const docsConfig = fs.existsSync(path.join(ROOT, CONFIG_PATH))
361
+ ? loadConfig(new Repo(ROOT, { noGit: true }), loadSchemaBundle().bundle)
362
+ : null;
363
+ const result = installTemplates(null, null, { write: true, root: ROOT, force: opt("force"), docsConfig });
357
364
  out(result, true);
358
365
  } else if (cmd === "eval") {
359
366
  const which = positional[0] || "routing";
package/bin/usage.txt CHANGED
@@ -5,6 +5,8 @@ agentdoc — portable documentation & software-catalog system
5
5
  agentdoc <command> --help every flag one command accepts (also: agentdoc help <command>)
6
6
 
7
7
  agentdoc init [--force] write a configuration and documentation skeleton
8
+ (docs skeleton goes to the configured docs.* paths;
9
+ agentdoc/docs/ when there is no configuration yet)
8
10
  agentdoc audit inventory the repository, classify every fact
9
11
  agentdoc scaffold [--write] generate a CREATE-mode skeleton from the audit
10
12
  agentdoc validate sources + schemas + semantic checks
package/core/audit.mjs CHANGED
@@ -19,6 +19,7 @@ import { classifyContractFile } from "./contracts.mjs";
19
19
  import { ContextIndex } from "./query.mjs";
20
20
  import { expandGlob, isRepoPath } from "./fsx.mjs";
21
21
  import { isNormative, NORMATIVE_SEMANTICS_PREFIX } from "./normative.mjs";
22
+ import { isRepoRoot, longestOwner } from "./paths.mjs";
22
23
 
23
24
  const DOC_EXT = /\.(md|mdx|adoc|rst|txt)$/;
24
25
 
@@ -143,11 +144,12 @@ export function audit(repo, cfg, res) {
143
144
  configured.push({ role, path: dir, configured: false });
144
145
  }
145
146
  }
146
- const unitRoots = new Set(res.discovery.units.map((u) => u.root));
147
- const insideComponent = (p) => {
148
- for (const r of unitRoots) if (p === r || p.startsWith(r + "/")) return r;
149
- return null;
150
- };
147
+ // The most specific unit root containing the path. A root unit is left out
148
+ // on purpose: it contains the whole repository, so counting it would make
149
+ // every repository-wide hierarchy document "component-scoped" and the
150
+ // distinction this check draws would disappear.
151
+ const unitRoots = [...new Set(res.discovery.units.map((u) => u.root))].filter((r) => !isRepoRoot(r));
152
+ const insideComponent = (p) => longestOwner(unitRoots, p);
151
153
 
152
154
  // The audit reports; it does not gate. These are therefore collected as audit
153
155
  // findings on the report rather than pushed into the compile-time diagnostics,
package/core/codes.mjs CHANGED
@@ -23,6 +23,7 @@ export const CODES = {
23
23
  GLOB: "AGENTDOC_GLOB",
24
24
  PATH_ESCAPE: "AGENTDOC_PATH_ESCAPE",
25
25
  PATH_UNRESOLVED: "AGENTDOC_PATH_UNRESOLVED",
26
+ MIGRATION_PATH: "AGENTDOC_MIGRATION_PATH",
26
27
  SECRET: "AGENTDOC_SECRET",
27
28
 
28
29
  // identity & references
@@ -98,6 +99,7 @@ export const ACCEPTABLE_WARNING_CODES = Object.freeze([
98
99
  "AGENTDOC_NORMATIVE_UNCOVERED",
99
100
  "AGENTDOC_REQUIREMENT_UNVERIFIED",
100
101
  "AGENTDOC_PYTHON_SPECIFIER",
102
+ "AGENTDOC_MIGRATION_DIR_UNRESOLVED",
101
103
  ]);
102
104
 
103
105
  export class AgentDocError extends Error {
package/core/compile.mjs CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  } from "./normative.mjs";
25
25
  import { loadAdapters } from "../adapters/index.mjs";
26
26
  import { loadCargoUnit, analyzeCargoDeps, rustIdentsInSource } from "../adapters/rust.mjs";
27
+ import { joinPath, ownsPath } from "./paths.mjs";
27
28
  import {
28
29
  COMPILER_NAME, COMPILER_VERSION, GRAPH_SCHEMA_VERSION,
29
30
  assembleGraph, serializeGraph, finalizeSerialization, subjectResolver,
@@ -300,7 +301,7 @@ export function compile(root, opts = {}) {
300
301
  // every graph that does not enable python.
301
302
  const pythonOn = adapters.some((a) => a.name === "python");
302
303
  for (const unit of discovery.eligible) {
303
- const pkgPath = unit.root + "/package.json";
304
+ const pkgPath = joinPath(unit.root, "package.json");
304
305
  if (repo.exists(pkgPath)) {
305
306
  try {
306
307
  const pkg = repo.readJson(pkgPath);
@@ -310,7 +311,7 @@ export function compile(root, opts = {}) {
310
311
  res.errors.push(new AgentDocError(CODES.SCHEMA, "package.json is not valid JSON: " + pkgPath, { path: pkgPath }));
311
312
  }
312
313
  }
313
- const gomodPath = unit.root + "/go.mod";
314
+ const gomodPath = joinPath(unit.root, "go.mod");
314
315
  if (repo.exists(gomodPath)) {
315
316
  const m = /^module\s+(\S+)/m.exec(repo.readText(gomodPath));
316
317
  unit.goModule = m ? m[1] : null;
@@ -398,7 +399,7 @@ export function compile(root, opts = {}) {
398
399
  if (t && t.component && t.component.ref !== unit.component.ref) deps.add(t.component.ref);
399
400
  }
400
401
  if (unit.goModule) {
401
- const gomod = repo.exists(unit.root + "/go.mod") ? repo.readText(unit.root + "/go.mod") : "";
402
+ const gomod = repo.exists(joinPath(unit.root, "go.mod")) ? repo.readText(joinPath(unit.root, "go.mod")) : "";
402
403
  const goReqs = [...gomod.matchAll(/^\s*(?:require\s+)?([a-z0-9.]+\.[a-z0-9./-]+)\s+v[0-9]/gm)].map((m) => m[1]);
403
404
  productionDependencies.set(unit.component.ref, [...(productionDependencies.get(unit.component.ref) || []), ...goReqs]);
404
405
  for (const m of gomod.matchAll(/^\s*(?:require\s+)?([a-z0-9.]+\.[a-z0-9./-]+)\s+v[0-9]/gm)) {
@@ -497,6 +498,10 @@ export function compile(root, opts = {}) {
497
498
  "entf:" + e.ref + ":/metadata/description",
498
499
  ];
499
500
  if (e.doc.spec !== undefined) subjects.push("entf:" + e.ref + ":/spec");
501
+ // A declared migration path is the one authored path whose meaning reaches
502
+ // past the descriptor (impact routes every change below it to this
503
+ // Resource), so it is pointed at directly rather than through `/spec`.
504
+ if (e.doc.spec?.migrationPaths) subjects.push("entf:" + e.ref + ":/spec/migrationPaths");
500
505
  prov.add("declared", e.file, "descriptor-reader", subjects);
501
506
  }
502
507
 
@@ -776,7 +781,7 @@ export function compile(root, opts = {}) {
776
781
  return earlyOut(res, { entities: sources.entities.length, units: discovery.eligible.length });
777
782
  }
778
783
  for (const d of derivedMap.values()) {
779
- for (const list of [d.bindings || [], d.healthContracts || []]) {
784
+ for (const list of [d.bindings || [], d.healthContracts || [], d.migrationDirs || []]) {
780
785
  for (const b of list) {
781
786
  b.provenanceIds = [...(b._provs || [])].map((p) => provFinal.idOf.get(p)).filter(Boolean).sort();
782
787
  delete b._provs;
@@ -796,14 +801,15 @@ export function compile(root, opts = {}) {
796
801
  ...schemaInputs,
797
802
  ...adapters.map((a) => ADAPTER_VERSION_INPUT_PREFIX + a.name + "@" + a.version),
798
803
  ]);
799
- const dirtySet = new Set(repo.dirtyPaths().filter((p) => p !== graphPath));
804
+ // The same git status snapshot the ignore oracle answered from (fsx.mjs).
805
+ const dirtySet = new Set(repo.dirtyPaths({ snapshot: true }).filter((p) => p !== graphPath));
800
806
  const walkedDirs = [...repo.walks.keys()];
801
807
  const dirty = [...dirtySet].some(
802
808
  (p) =>
803
809
  repo.readPaths.has(p) ||
804
810
  repo.probes.has(p) ||
805
811
  repo.listings.has(p) ||
806
- walkedDirs.some((d) => (d === "." ? true : p === d || p.startsWith(d + "/")))
812
+ walkedDirs.some((d) => ownsPath(d, p))
807
813
  );
808
814
  graph.source = { commit, dirty, inputHash };
809
815
 
package/core/derive.mjs CHANGED
@@ -7,6 +7,7 @@
7
7
  import path from "node:path";
8
8
  import { assertRepoPath } from "./fsx.mjs";
9
9
  import { CODES } from "./codes.mjs";
10
+ import { joinPath } from "./paths.mjs";
10
11
 
11
12
  const DEFAULT_DOC_CANDIDATES = ["README.md", "ARCHITECTURE.md", "AGENTS.md", "CONTRIBUTING.md", "DESIGN.md", "OVERVIEW.md"];
12
13
  const DEFAULT_CONSTRAINT_CANDIDATES = ["CONSTRAINTS.md", "INVARIANTS.md"];
@@ -33,15 +34,15 @@ export function genericFacts(ctx, unit) {
33
34
  const constraints = [];
34
35
  const runbooks = [];
35
36
  for (const n of docNames) {
36
- const p = root + "/" + n;
37
+ const p = joinPath(root, n);
37
38
  if (repo.exists(p)) { docs.push(p); prov.add("observed", p, "docs-extractor", ["der:" + compRef + ":/docs"]); }
38
39
  }
39
40
  for (const n of constraintNames) {
40
- const p = root + "/" + n;
41
+ const p = joinPath(root, n);
41
42
  if (repo.exists(p)) { constraints.push(p); prov.add("observed", p, "docs-extractor", ["der:" + compRef + ":/constraints"]); }
42
43
  }
43
44
  for (const n of runbookNames) {
44
- const p = root + "/" + n;
45
+ const p = joinPath(root, n);
45
46
  if (repo.exists(p)) { runbooks.push(p); prov.add("observed", p, "docs-extractor", ["der:" + compRef + ":/runbooks"]); }
46
47
  }
47
48
  const spec = ctx.componentEntity().doc.spec || {};
@@ -20,6 +20,7 @@ import { parseYamlDocuments } from "./yaml.mjs";
20
20
  import { scanForSecrets } from "./secrets.mjs";
21
21
  import { formatErrors } from "./jsonschema.mjs";
22
22
  import { SchemaBundle } from "./jsonschema.mjs";
23
+ import { joinPath, ownsPath } from "./paths.mjs";
23
24
 
24
25
  export const KINDS = Object.freeze(["Domain", "System", "Component", "API", "Resource"]);
25
26
  const NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
@@ -177,7 +178,7 @@ function descriptorGlobs(repo, cfg) {
177
178
  // glob — its descriptor lives at a fixed path and must be loaded even when
178
179
  // no componentDescriptors glob happens to cover that directory.
179
180
  for (const r of cfg.discovery.supplementalRoots || []) {
180
- const f = String(r).replace(/\/+$/, "") + "/" + cfg.discovery.componentDescriptorName;
181
+ const f = joinPath(String(r), cfg.discovery.componentDescriptorName);
181
182
  if (repo.exists(f)) componentFiles.add(f);
182
183
  }
183
184
  const centralFiles = new Set();
@@ -296,6 +297,7 @@ export function loadDescriptors(repo, cfg, bundle, refs) {
296
297
  throw new AgentDocError(CODES.PATH_UNRESOLVED, "context link does not resolve: " + p, { path: e.file, ref: e.ref });
297
298
  }
298
299
  }
300
+ if (e.kind === "Resource" && spec.migrationPaths) checkMigrationPaths(repo, e);
299
301
  if (e.kind === "API") {
300
302
  const prov = spec.provider;
301
303
  if (!refs.isValidRef(prov) || refs.kindOfRef(prov) !== "Component") {
@@ -334,9 +336,57 @@ export function loadDescriptors(repo, cfg, bundle, refs) {
334
336
  }, errors);
335
337
  }
336
338
 
339
+ // One directory migrates one database. Two Resources declaring the same path
340
+ // would make every migration under it ambiguous, and impact must not pick.
341
+ // Nested declarations are fine: the longest prefix wins, as for ownership.
342
+ const declaredBy = new Map();
343
+ for (const e of entities) {
344
+ if (e.kind !== "Resource") continue;
345
+ for (const p of e.doc.spec?.migrationPaths || []) {
346
+ if (!declaredBy.has(p)) declaredBy.set(p, []);
347
+ declaredBy.get(p).push(e);
348
+ }
349
+ }
350
+ for (const [p, owners] of [...declaredBy.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
351
+ if (owners.length < 2) continue;
352
+ const names = owners.map((o) => o.ref).sort();
353
+ errors.push(new AgentDocError(
354
+ CODES.MIGRATION_PATH,
355
+ "migration path " + JSON.stringify(p) + " is declared by several Resources (" + names.join(", ") + "); a migration directory migrates one logical database",
356
+ { path: owners[0].file, ref: names[0] }
357
+ ));
358
+ }
359
+
337
360
  return { entities, byRef, byKindName, componentFiles: new Set(componentFiles), errors, componentSet, centralSet };
338
361
  }
339
362
 
363
+ // `spec.migrationPaths` on a Resource: repository directories (or files) whose
364
+ // changes are changes to this database. They are whole-segment prefixes with
365
+ // the semantics of `core/paths.mjs`, never globs: a glob would need an engine
366
+ // this system does not ship, and `drizzle/**` means nothing a prefix does not.
367
+ function checkMigrationPaths(repo, e) {
368
+ if (e.doc.spec.type !== "logical-database") {
369
+ throw new AgentDocError(
370
+ CODES.MIGRATION_PATH,
371
+ "spec.migrationPaths is only meaningful on a logical-database Resource, not on type '" + e.doc.spec.type + "'",
372
+ { path: e.file, ref: e.ref }
373
+ );
374
+ }
375
+ for (const p of e.doc.spec.migrationPaths) {
376
+ assertRepoPath(p, e.file);
377
+ if (/[*?[\]{}!]/.test(p)) {
378
+ throw new AgentDocError(
379
+ CODES.MIGRATION_PATH,
380
+ "migration path " + JSON.stringify(p) + " is a glob; declare the directory itself (e.g. \"" + p.replace(/\/?[^/]*[*?[\]{}!].*$/, "") + "\"), which covers everything below it",
381
+ { path: e.file, ref: e.ref }
382
+ );
383
+ }
384
+ if (!repo.exists(p)) {
385
+ throw new AgentDocError(CODES.PATH_UNRESOLVED, "migration path does not resolve: " + p, { path: e.file, ref: e.ref });
386
+ }
387
+ }
388
+ }
389
+
340
390
  export function loadJourneys(repo, cfg, bundle, byRef) {
341
391
  const errors = [];
342
392
  const jPath = cfg.discovery.journeyDefinitions;
@@ -473,7 +523,7 @@ export function loadReviewedOverrides(repo, cfg, refs, byRef) {
473
523
  : null;
474
524
  for (const ev of o.evidence) {
475
525
  const p = ev.path;
476
- const scoped = p === o.contractRef || p.startsWith(subjectRoot + "/") || (targetRoot && p.startsWith(targetRoot + "/"));
526
+ const scoped = p === o.contractRef || ownsPath(subjectRoot, p) || (targetRoot !== null && ownsPath(targetRoot, p));
477
527
  if (!scoped) {
478
528
  throw new AgentDocError(
479
529
  CODES.RELATION_KIND,
package/core/discover.mjs CHANGED
@@ -7,6 +7,7 @@
7
7
  import path from "node:path";
8
8
  import { AgentDocError, CODES } from "./codes.mjs";
9
9
  import { expandGlob, globToRegExp, assertGlob } from "../core/fsx.mjs";
10
+ import { joinPath, ownsPath } from "./paths.mjs";
10
11
 
11
12
  // Roots excluded from inference and scaffolding. Authored descriptors strictly
12
13
  // below them can still claim units; descriptors at these exact roots cannot.
@@ -20,7 +21,7 @@ function notUnitRoots(cfg) {
20
21
  // mode shares this policy even for units with an existing authored descriptor.
21
22
  export function isNotUnitRoot(root, cfg) {
22
23
  return notUnitRoots(cfg)
23
- .some((excluded) => root === excluded || root.startsWith(excluded + "/"));
24
+ .some((excluded) => ownsPath(excluded, root));
24
25
  }
25
26
 
26
27
  export function discoverUnits(repo, cfg, adapters) {
@@ -54,7 +55,7 @@ export function discoverUnits(repo, cfg, adapters) {
54
55
  for (const r of cfg.discovery.supplementalRoots || []) {
55
56
  candidates.add(r);
56
57
  // The descriptor loader also reads these fixed paths outside the globs.
57
- if (repo.exists(r + "/" + cfg.discovery.componentDescriptorName)) descriptorRoots.add(r);
58
+ if (repo.exists(joinPath(r, cfg.discovery.componentDescriptorName))) descriptorRoots.add(r);
58
59
  }
59
60
 
60
61
  // Adapters may contribute roots the descriptor globs cannot know about: a