bc-atlas 0.12.0 → 0.14.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,63 @@ All notable changes to BC Atlas will be documented here. The format is based on
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project
5
5
  follows [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## 0.14.0 - 2026-11-05
8
+
9
+ ### Added
10
+
11
+ - A root `.mcp.json` starts BC Atlas from the local checkout for direct MCP
12
+ tool calls in Copilot-compatible hosts. The MCP integration test uses the
13
+ same configuration, and the agent guide explains setup and server approval.
14
+
15
+ ### Fixed
16
+
17
+ - Git-history analysis waits for its `git cat-file` reader to exit before
18
+ removing temporary snapshots, avoiding file-lock failures on Windows.
19
+
20
+ ## 0.13.0 - 2026-11-04
21
+
22
+ ### Added
23
+
24
+ - `bca check` flags interface methods whose enum implementation can complete
25
+ silently: an empty body, a constant `true` return, or a conditional path
26
+ with no error (`contract-fallback`). Without configuration these are
27
+ informational; `check.contractExpectations` names the interface methods
28
+ that must not fall back silently and turns them into warnings. Unresolved
29
+ delegation, symbol-only bodies, and ambiguous signatures stay unknown
30
+ instead of counting as safe. See [Configuration](./docs/configuration.md).
31
+ - `check.contexts` summarizes the responsibilities of selected objects:
32
+ reads, persistent and temporary writes, commits, resolved calls, and global
33
+ state, direct and up to `maxCallDepth` calls away. `roles`,
34
+ `lazyReadMethods`, and `forbid` turn them into policy warnings; bounded or
35
+ unresolved evidence is reported with its limitations.
36
+ - Architecture component selectors accept `file:` (one file, globs allowed),
37
+ more specific than a namespace, folder, or ID range.
38
+ - Enum bindings keep `DefaultImplementation` and `UnknownValueImplementation`
39
+ as evidence, and calls keep their arguments.
40
+
41
+ ### Changed
42
+
43
+ - Health and architecture findings carry their basis (`configured-policy`,
44
+ `observed-code-fact`, or `heuristic` for a proposed architecture), a
45
+ primary location, all evidence, and limitations. Text and Markdown show up
46
+ to five locations; JSON and SARIF keep everything.
47
+ - Component cycles are explained by a representative path with the
48
+ dependencies behind each hop, and, where several providers call back into
49
+ the same orchestrator procedure, suggest extracting a shared service.
50
+ - `bca refactor move` defaults to the common parent folder of a component's
51
+ objects instead of the folder most of them are in.
52
+ - Records of pages and tables with `SourceTableTemporary` count as temporary,
53
+ and record parameters of unknown temporariness are no longer reported as
54
+ persistent writes.
55
+ - Release: one *Release* workflow publishes the npm package and the VS Code
56
+ extension under one version, after testing both, and attaches the `.vsix`
57
+ to the GitHub release.
58
+
59
+ ### Fixed
60
+
61
+ - Health reports no longer print `architecture origin: undefined`, and
62
+ Markdown evidence cells with `|` no longer break the table.
63
+
7
64
  ## 0.12.0 - 2026-10-29
8
65
 
9
66
  ### Added
package/README.md CHANGED
@@ -44,7 +44,7 @@ In VS Code, install the **BC Atlas** extension and open an AL project:
44
44
  | **Export** | `bca export` | Everything the extension shows, as files: the BC Atlas pages as one offline HTML file to share (default), the Markdown report ([example](./examples/report/README.md)), the 3D explorer, one Markdown page per object, any of ten diagram views as D2, SVG, PNG, PDF, or JSON, with `--watch`, and the project as a knowledge graph in RDF with an OWL ontology ([Turtle, RDF/XML, OWL, N-Triples, JSON-LD](./docs/knowledge-graph.md)). |
45
45
  | | `bca docs` | User guides and test cases from AL UI tests (`[SCENARIO]`, `[GIVEN]`, `[WHEN]`, `[THEN]`) as Markdown, HTML, DITA 1.3, or Azure DevOps CSV, with real captions and translations, a flow diagram per scenario, and BPMN 2.0 processes per use case. [Example](./examples/docs/index.md) |
46
46
  | **Integrate** | `bca run` | One entry point for Git hooks, schedules, and pipelines; verifies, stages, or publishes generated files. Also available as a [GitHub Action](./docs/automation-and-ci.md#github-actions). |
47
- | | `bca update` | "BC Atlas 0.13.0 is available": release notes, one-step update, and regenerated hooks, tasks, and pipelines. Dependabot pull requests on request. |
47
+ | | `bca update` | "BC Atlas 0.14.0 is available": release notes, one-step update, and regenerated hooks, tasks, and pipelines. Dependabot pull requests on request. |
48
48
  | | `bca mcp` | A Model Context Protocol server for AI agents; `bca help --format json` is the versioned command contract. |
49
49
 
50
50
  Run `bca` for the same overview in your terminal and `bca help <command>` for
@@ -259,27 +259,25 @@ lists planned work.
259
259
 
260
260
  ### Publishing
261
261
 
262
- Releases are published manually through the `Publish npm package` workflow.
263
- Update and commit the version in `package.json` and `package-lock.json`, then
264
- enter that exact version in the workflow form. The workflow rejects mismatched
265
- or already published versions, runs all checks, and publishes with the selected
266
- dist-tag. It then tags the commit as `v<version>`, so
262
+ Releases are published manually through the `Release` workflow
263
+ (`.github/workflows/publish-npm.yml`). Set the version of both packages with
264
+ `npm run version:set -- <version>`, merge it, then run the workflow on `main`
265
+ with that exact version. It rejects mismatched, already published, or already
266
+ tagged versions, and tests the npm package and the VS Code extension, the
267
+ packaged `.vsix` included, before anything is published. Then it publishes the
268
+ npm package with the selected dist-tag and, unless switched off, the extension
269
+ to the Visual Studio Marketplace and, with the secret `OVSX_PAT`, to Open VSX.
270
+ Last, it tags the commit as `v<version>`, so
267
271
  `uses: SchulzOli/BC-Atlas@v<version>` resolves, and creates a GitHub release
268
- with the version's `CHANGELOG.md` section. npm authenticates through
272
+ with the version's `CHANGELOG.md` section and the `.vsix`.
273
+
274
+ No token is stored for either registry. npm authenticates through
269
275
  [trusted publishing](https://docs.npmjs.com/trusted-publishers): the package's
270
276
  trusted publisher on npmjs.com is this repository's `publish-npm.yml`
271
- workflow, so no npm token is stored in GitHub.
272
-
273
-
274
- The VS Code extension in `vscode/` is published by the `Publish VS Code
275
- extension` workflow: set `version` in `vscode/package.json`, then run the
276
- workflow with that version. It tests and packages the extension with the
277
- current engine and publishes it to the Visual Studio Marketplace as the
278
- publisher `SchulzOli`, signed in with Microsoft Entra ID through GitHub's OIDC
279
- token (no Marketplace token is stored; see the
280
- [launch checklist](https://github.com/SchulzOli/BC-Atlas/blob/main/vscode/marketing/README.md#launch-checklist)), and, with
281
- the secret `OVSX_PAT`, to Open VSX. Every CI run uploads the `.vsix` as the
282
- `bc-atlas-vsix` artifact.
277
+ workflow. The Marketplace publisher `SchulzOli` is signed in with Microsoft
278
+ Entra ID through GitHub's OIDC token in the `marketplace` environment (see the
279
+ [launch checklist](https://github.com/SchulzOli/BC-Atlas/blob/main/vscode/marketing/README.md#launch-checklist)).
280
+ Every CI run uploads the `.vsix` as the `bc-atlas-vsix` artifact.
283
281
 
284
282
  ## Contributing and support
285
283
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bc-atlas",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "BC Atlas maps Microsoft Dynamics 365 Business Central AL projects: architecture diagrams, health checks, reports, and documentation generated from source.",
5
5
  "homepage": "https://github.com/SchulzOli/BC-Atlas#readme",
6
6
  "bugs": {
package/src/analyzer.js CHANGED
@@ -293,10 +293,18 @@ function enclosingMember(node, source, types) {
293
293
  return undefined;
294
294
  }
295
295
 
296
- function implementationRelations(value, node, source, add) {
296
+ function implementationRelations(property, value, node, source, add) {
297
+ const bindingKind = {
298
+ implementation: "explicit",
299
+ defaultimplementation: "default",
300
+ unknownvalueimplementation: "unknownValue"
301
+ }[property];
302
+ if (!bindingKind) return;
297
303
  const identifier = String.raw`(?:"((?:""|[^"])*)"|([A-Za-z_][\w.]*))`;
298
304
  const pattern = new RegExp(`${identifier}\\s*=\\s*${identifier}`, "gu");
299
- const member = enclosingMember(node, source, ["enum_value_declaration"]);
305
+ const member = bindingKind === "explicit"
306
+ ? enclosingMember(node, source, ["enum_value_declaration"])
307
+ : undefined;
300
308
  for (const match of value.matchAll(pattern)) {
301
309
  const contract = (match[1] ?? match[2]).replaceAll('""', '"');
302
310
  const target = (match[3] ?? match[4]).replaceAll('""', '"');
@@ -304,6 +312,7 @@ function implementationRelations(value, node, source, add) {
304
312
  target,
305
313
  targetType: "codeunit",
306
314
  kind: "selects",
315
+ bindingKind,
307
316
  contract,
308
317
  member
309
318
  });
@@ -496,8 +505,8 @@ function referencesFor(objectNode, source, file) {
496
505
  if (name === "includedpermissionsets") {
497
506
  includedPermissionSetRelations(value, node, add);
498
507
  }
499
- if (name === "implementation") {
500
- implementationRelations(value, node, source, add);
508
+ if (["implementation", "defaultimplementation", "unknownvalueimplementation"].includes(name)) {
509
+ implementationRelations(name, value, node, source, add);
501
510
  }
502
511
  if (name === "runobject") {
503
512
  const match = value.match(
@@ -533,6 +542,9 @@ function callsFor(node, source, file) {
533
542
  ? functionNode.childForFieldName("object")
534
543
  : undefined;
535
544
  const argumentsNode = callNode.childForFieldName("arguments");
545
+ const argumentsList = argumentsNode?.namedChildren.map((argument) =>
546
+ source.slice(argument.startIndex, argument.endIndex).trim()
547
+ ) ?? [];
536
548
  return {
537
549
  name: cleanName(source.slice(
538
550
  (memberNode ?? functionNode).startIndex,
@@ -543,6 +555,7 @@ function callsFor(node, source, file) {
543
555
  : undefined,
544
556
  receiverCall: receiverNode?.type === "call_expression" ? describe(receiverNode) : undefined,
545
557
  arity: argumentsNode?.namedChildCount ?? 0,
558
+ ...(argumentsList.length ? { arguments: argumentsList } : {}),
546
559
  scope: node.startPosition.row + 1,
547
560
  location: locationFor(callNode, file)
548
561
  };
@@ -610,6 +623,149 @@ function variableType(node, source) {
610
623
  return declaredType(type);
611
624
  }
612
625
 
626
+ function fallbackStatements(node, source, file, conditional = false) {
627
+ if (!node) return [];
628
+ if (node.type === "statement_block") {
629
+ return node.namedChildren.flatMap((child) =>
630
+ fallbackStatements(child, source, file, conditional)
631
+ );
632
+ }
633
+ if (node.type === "if_statement") {
634
+ return [{
635
+ kind: "conditional",
636
+ then: fallbackStatements(node.childForFieldName("then_branch"), source, file, true),
637
+ else: node.childForFieldName("else_branch")
638
+ ? fallbackStatements(node.childForFieldName("else_branch"), source, file, true)
639
+ : null,
640
+ location: locationFor(node, file)
641
+ }];
642
+ }
643
+ if (node.type === "case_statement") {
644
+ const body = node.childForFieldName("body");
645
+ const branches = (body?.namedChildren ?? [])
646
+ .filter((child) => child.type === "case_branch")
647
+ .map((branch) => fallbackStatements(
648
+ branch.childForFieldName("body"),
649
+ source,
650
+ file,
651
+ true
652
+ ));
653
+ const elseBranch = node.namedChildren.find((child) => child.type === "case_else_branch");
654
+ if (elseBranch) {
655
+ branches.push(elseBranch.namedChildren
656
+ .filter((child) => child.type !== "else_keyword")
657
+ .flatMap((child) => fallbackStatements(child, source, file, true)));
658
+ }
659
+ return [{
660
+ kind: "conditional",
661
+ branches,
662
+ hasImplicitEmptyPath: !elseBranch,
663
+ location: locationFor(node, file)
664
+ }];
665
+ }
666
+ if (["while_statement", "for_statement", "repeat_statement"].includes(node.type)) {
667
+ const body = node.childForFieldName("body");
668
+ return [{
669
+ kind: "conditional",
670
+ branches: [fallbackStatements(body, source, file, true)],
671
+ hasImplicitEmptyPath: node.type !== "repeat_statement",
672
+ location: locationFor(node, file)
673
+ }];
674
+ }
675
+ if (node.type === "call_expression") {
676
+ const functionNode = node.childForFieldName("function");
677
+ const memberNode = functionNode?.type === "member_expression"
678
+ ? functionNode.childForFieldName("member")
679
+ : undefined;
680
+ const receiverNode = functionNode?.type === "member_expression"
681
+ ? functionNode.childForFieldName("object")
682
+ : undefined;
683
+ const target = functionNode
684
+ ? cleanName(source.slice(
685
+ (memberNode ?? functionNode).startIndex,
686
+ (memberNode ?? functionNode).endIndex
687
+ ))
688
+ : undefined;
689
+ const argumentsNode = node.childForFieldName("arguments");
690
+ return [{
691
+ kind: "call",
692
+ target,
693
+ receiver: receiverNode
694
+ ? cleanName(source.slice(receiverNode.startIndex, receiverNode.endIndex))
695
+ : undefined,
696
+ arity: argumentsNode?.namedChildCount ?? 0,
697
+ directError: !receiverNode && target?.toLocaleLowerCase("en-US") === "error",
698
+ conditional,
699
+ location: locationFor(node, file)
700
+ }];
701
+ }
702
+ if (node.type === "exit_statement") {
703
+ const returnNode = node.childForFieldName("return_value");
704
+ return [{
705
+ kind: "return",
706
+ value: returnNode ? source.slice(returnNode.startIndex, returnNode.endIndex).trim() : undefined,
707
+ constant: Boolean(returnNode && [
708
+ "boolean",
709
+ "integer",
710
+ "decimal",
711
+ "string_literal"
712
+ ].includes(returnNode.type)),
713
+ conditional,
714
+ location: locationFor(node, file)
715
+ }];
716
+ }
717
+ if (node.type === "assignment_statement") {
718
+ const targetNode = node.childForFieldName("left");
719
+ return [{
720
+ kind: "assignment",
721
+ target: targetNode
722
+ ? cleanName(source.slice(targetNode.startIndex, targetNode.endIndex))
723
+ : undefined,
724
+ conditional,
725
+ location: locationFor(node, file)
726
+ }];
727
+ }
728
+ return [{
729
+ kind: "other",
730
+ conditional,
731
+ location: locationFor(node, file)
732
+ }];
733
+ }
734
+
735
+ function fallbackFactsFor(node, source, file, hasBody) {
736
+ if (!hasBody) return undefined;
737
+ const codeBlock = node.namedChildren.find((child) =>
738
+ ["code_block", "statement_block"].includes(child.type)
739
+ );
740
+ const body = codeBlock?.childForFieldName("body") ??
741
+ codeBlock?.namedChildren.find((child) => child.type === "statement_block");
742
+ return {
743
+ complete: !node.hasError && !codeBlock?.hasError,
744
+ statements: fallbackStatements(body, source, file),
745
+ ...((node.hasError || codeBlock?.hasError)
746
+ ? { limitations: ["The AL parser reported errors in this procedure body."] }
747
+ : {})
748
+ };
749
+ }
750
+
751
+ function resolveFallbackCalls(statements, procedures) {
752
+ for (const statement of statements) {
753
+ if (statement.kind === "conditional") {
754
+ if (statement.then) resolveFallbackCalls(statement.then, procedures);
755
+ if (statement.else) resolveFallbackCalls(statement.else, procedures);
756
+ for (const branch of statement.branches ?? []) {
757
+ resolveFallbackCalls(branch, procedures);
758
+ }
759
+ continue;
760
+ }
761
+ if (statement.kind !== "call" || statement.directError) continue;
762
+ const candidates = procedures.get(`${statement.target?.toLocaleLowerCase("en-US")}:${statement.arity}`) ?? [];
763
+ const target = !statement.receiver && candidates.length === 1 ? candidates[0] : undefined;
764
+ statement.resolved = Boolean(target?.fallbackFacts?.complete);
765
+ if (target) statement.targetSignature = target.signature;
766
+ }
767
+ }
768
+
613
769
  function membersFor(objectNode, source, file) {
614
770
  const procedures = [];
615
771
  const fields = [];
@@ -624,18 +780,24 @@ function membersFor(objectNode, source, file) {
624
780
  const name = fieldText(node, "name", source);
625
781
  if (name) {
626
782
  const returnTypeNode = node.childForFieldName("return_type");
627
- const returnType = returnTypeNode
628
- ? declaredType(source.slice(returnTypeNode.startIndex, returnTypeNode.endIndex).trim())
629
- : {};
783
+ const header = procedureHeader(node, source);
784
+ const returnTypeText = returnTypeNode
785
+ ? source.slice(returnTypeNode.startIndex, returnTypeNode.endIndex).trim()
786
+ : header.match(/\)\s*:\s*([^;\r\n]+)$/u)?.[1]?.trim();
787
+ const returnType = declaredType(returnTypeText);
630
788
  const attributes = [
631
789
  ...node.namedChildren
632
790
  .filter((child) => child.type === "attribute_item")
633
791
  .map((child) => source.slice(child.startIndex, child.endIndex)),
634
792
  ...attributesFor(node, source)
635
793
  ];
794
+ const hasBody = node.namedChildren.some((child) =>
795
+ ["code_block", "statement_block"].includes(child.type)
796
+ );
797
+ const fallbackFacts = fallbackFactsFor(node, source, file, hasBody);
636
798
  procedures.push({
637
799
  name,
638
- header: procedureHeader(node, source),
800
+ header,
639
801
  visibility: node.type === "trigger_declaration"
640
802
  ? undefined
641
803
  : fieldText(node, "modifier", source)?.toLowerCase() ?? "public",
@@ -647,6 +809,8 @@ function membersFor(objectNode, source, file) {
647
809
  : attributes.some((attribute) => /\[(?:Integration|Business)Event\b/iu.test(attribute))
648
810
  ? "event"
649
811
  : "procedure",
812
+ hasBody,
813
+ ...(fallbackFacts ? { fallbackFacts } : {}),
650
814
  location: locationFor(node, file),
651
815
  calls: callsFor(node, source, file),
652
816
  attributes,
@@ -825,6 +989,18 @@ function membersFor(objectNode, source, file) {
825
989
  .map(({ order, location }) => ({ kind: "commit", order, location }));
826
990
  procedure.transactionSegments = procedure.transactionBoundaries.length + 1;
827
991
  }
992
+ const localProcedures = new Map();
993
+ for (const procedure of procedures) {
994
+ const key = `${procedure.name.toLocaleLowerCase("en-US")}:${procedure.arity}`;
995
+ const candidates = localProcedures.get(key) ?? [];
996
+ candidates.push(procedure);
997
+ localProcedures.set(key, candidates);
998
+ }
999
+ for (const procedure of procedures) {
1000
+ if (procedure.fallbackFacts) {
1001
+ resolveFallbackCalls(procedure.fallbackFacts.statements, localProcedures);
1002
+ }
1003
+ }
828
1004
  const transactionBoundaries = procedures.flatMap((procedure) =>
829
1005
  procedure.transactionBoundaries.map((boundary) => ({
830
1006
  ...boundary,
@@ -854,8 +1030,8 @@ function dataOperationRelations(members, object) {
854
1030
  .map((variable) => [variable.name.toLowerCase(), variable])
855
1031
  );
856
1032
  if (sourceTable) {
857
- variables.set("rec", { target: sourceTable, temporary: false });
858
- variables.set("xrec", { target: sourceTable, temporary: false });
1033
+ variables.set("rec", { target: sourceTable, temporary: object.sourceTableTemporary });
1034
+ variables.set("xrec", { target: sourceTable, temporary: object.sourceTableTemporary });
859
1035
  }
860
1036
  let transactionSegment = 1;
861
1037
  for (const call of procedure.calls) {
@@ -874,7 +1050,10 @@ function dataOperationRelations(members, object) {
874
1050
  kind,
875
1051
  relationClass: "runtime",
876
1052
  operation: call.name,
877
- temporary: variable.temporary,
1053
+ recordVariable: call.receiver,
1054
+ temporary: variable.parameter && variable.temporary !== true
1055
+ ? undefined
1056
+ : variable.temporary,
878
1057
  member: procedure.signature,
879
1058
  sourceProcedure: procedure.signature,
880
1059
  transactionSegment,
@@ -885,6 +1064,24 @@ function dataOperationRelations(members, object) {
885
1064
  return relations;
886
1065
  }
887
1066
 
1067
+ function subscriberRelations(procedures) {
1068
+ const relations = [];
1069
+ for (const procedure of procedures) {
1070
+ for (const attribute of procedure.attributes ?? []) {
1071
+ const match = attribute.match(/^\[\s*EventSubscriber\s*\(\s*ObjectType::(Codeunit|Table|Page|Report|XmlPort)\s*,\s*(?:Codeunit|Database|Page|Report|XmlPort)::\s*(?:"((?:""|[^"])*)"|([^,\]\s]+))/iu);
1072
+ if (!match) continue;
1073
+ relations.push({
1074
+ target: (match[2] ?? match[3]).replaceAll('""', '"'),
1075
+ targetType: match[1].toLowerCase(),
1076
+ kind: "subscribes",
1077
+ member: procedure.name,
1078
+ location: procedure.location
1079
+ });
1080
+ }
1081
+ }
1082
+ return relations;
1083
+ }
1084
+
888
1085
  function helperDataOperationRelations(members, directRelations) {
889
1086
  const procedures = new Map();
890
1087
  for (const procedure of members.procedures) {
@@ -971,7 +1168,18 @@ async function objectsFromSource(source, file) {
971
1168
  });
972
1169
  }
973
1170
  const members = membersFor(node, source, file);
974
- const dataRelations = dataOperationRelations(members, { type, name, base, relations });
1171
+ const properties = propertiesFor(node, source);
1172
+ const sourceTableTemporary = /^true$/iu.test(
1173
+ propertyValueFor(node, source, "sourcetabletemporary") ?? "false"
1174
+ );
1175
+ relations.push(...subscriberRelations(members.procedures));
1176
+ const dataRelations = dataOperationRelations(members, {
1177
+ type,
1178
+ name,
1179
+ base,
1180
+ relations,
1181
+ sourceTableTemporary
1182
+ });
975
1183
  relations.push(...dataRelations, ...helperDataOperationRelations(members, dataRelations));
976
1184
 
977
1185
  const assignableValue = type === "permissionset"
@@ -987,7 +1195,7 @@ async function objectsFromSource(source, file) {
987
1195
  namespace,
988
1196
  usings,
989
1197
  file,
990
- properties: propertiesFor(node, source),
1198
+ properties,
991
1199
  relations,
992
1200
  procedures: members.procedures,
993
1201
  fields: members.fields,
@@ -2,10 +2,11 @@ import { normalizeIdentifier } from "../resolver.js";
2
2
 
3
3
  // A component is a named part of the target architecture (a future app, or a
4
4
  // layer inside one). Objects are assigned by selectors; the most specific
5
- // matching selector wins (object, then the longest namespace or folder, then
6
- // the narrowest ID range, then module and app, then type, then *):
5
+ // matching selector wins (object, then file, then the longest namespace or
6
+ // folder, then the narrowest ID range, then module and app, then type, then *):
7
7
  // namespace:Contoso.Sales the namespace and everything below it
8
8
  // folder:src/Sales files below a folder (globs allowed)
9
+ // file:src/Sales/Order.al one file (globs allowed)
9
10
  // app:Contoso Sales an app by name or ID
10
11
  // module:Job Queue a module (nested app.json) by name or ID
11
12
  // type:table an object type
@@ -52,7 +53,7 @@ function compileSingle(selector, { apps, modules, componentOf, layerOf }) {
52
53
  if (text === "*" || text === "**") return () => 1;
53
54
  const separator = text.indexOf(":");
54
55
  if (separator < 1) {
55
- throw new Error(`Invalid component selector "${text}"; use namespace:, folder:, app:, module:, type:, object:, id:, or *`);
56
+ throw new Error(`Invalid component selector "${text}"; use namespace:, folder:, file:, app:, module:, type:, object:, id:, or *`);
56
57
  }
57
58
  const kind = text.slice(0, separator).toLowerCase();
58
59
  const value = text.slice(separator + 1).trim();
@@ -80,6 +81,15 @@ function compileSingle(selector, { apps, modules, componentOf, layerOf }) {
80
81
  }
81
82
  return (node) => Boolean(node.file) && (node.file === folder || node.file.startsWith(`${folder}/`)) && specificity;
82
83
  }
84
+ if (kind === "file") {
85
+ const file = value.replaceAll("\\", "/").replace(/^\.?\//u, "");
86
+ const specificity = 200 + file.replaceAll("*", "").replaceAll("?", "").length;
87
+ const expression = isGlob(file) ? globToRegExp(file) : undefined;
88
+ return (node) => {
89
+ const candidate = String(node.file ?? "").replaceAll("\\", "/");
90
+ return (expression ? expression.test(candidate) : candidate === file) && specificity;
91
+ };
92
+ }
83
93
  if (kind === "app") {
84
94
  return (node) => {
85
95
  const app = apps.get(node.app);
@@ -118,7 +128,7 @@ function compileSingle(selector, { apps, modules, componentOf, layerOf }) {
118
128
  return (node) => (!type || node.type === type.toLowerCase()) &&
119
129
  [node.label, node.objectId].filter(Boolean).some((candidate) => normalizeIdentifier(candidate) === name) && 1000;
120
130
  }
121
- throw new Error(`Unsupported selector kind "${kind}"; use namespace, folder, app, module, type, object, or id${componentOf ? ", component, or layer" : ""}`);
131
+ throw new Error(`Unsupported selector kind "${kind}"; use namespace, folder, file, app, module, type, object, or id${componentOf ? ", component, or layer" : ""}`);
122
132
  }
123
133
 
124
134
  /** Apps and modules by id, for selectors that name them. */