@passioncode-ai/fabric-agent-adapter 0.79.1 → 0.81.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 (120) hide show
  1. package/CHANGELOG.md +213 -0
  2. package/README.md +13 -2
  3. package/SECURITY.md +23 -8
  4. package/bin/fabric-agent-adapter.js +50 -5
  5. package/package.json +1 -1
  6. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +6 -4
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/agents-database.md +4 -4
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +12 -4
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +1 -1
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +44 -7
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +107 -2
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +26 -6
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +9 -1
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +24 -5
  16. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +57 -76
  17. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_restart.py +56 -0
  18. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_tree.py +912 -0
  19. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/new_agent.py +308 -0
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +8 -4
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/README.md +67 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/dashboard_service.py +252 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/install.mjs +168 -0
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/install.py +179 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/README.md +57 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/door.mjs +181 -0
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/door.py +237 -0
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +1 -1
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +4 -4
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +4 -2
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +12 -4
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +7 -3
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +12 -1
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +54 -3
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +6 -3
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +33 -12
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +1 -0
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +10 -0
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +25 -7
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +44 -3
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +21 -0
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +26 -6
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +24 -33
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +12 -6
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +136 -3
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +3 -0
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +1 -0
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs +37 -5
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +118 -3
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +266 -0
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +72 -7
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +16 -2
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-load.mjs +344 -0
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +98 -1
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +7 -3
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +1 -1
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +10 -3
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +11 -0
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +114 -20
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +8 -1
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +3 -2
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +2 -0
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +97 -3
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +50 -8
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +120 -3
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +316 -0
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +40 -3
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +15 -2
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +2 -2
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_load.py +358 -0
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +103 -1
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +28 -13
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +1 -1
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +24 -5
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +23 -0
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +110 -9
  77. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +12 -1
  78. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +469 -29
  79. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +4 -1
  80. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +18 -6
  81. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +44 -0
  82. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +4 -4
  83. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/communication-agent-template.md +19 -328
  84. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +12 -4
  85. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +1 -1
  86. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/new-agent.md +42 -0
  87. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +7 -1
  88. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +44 -7
  89. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +28 -0
  90. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/working-rules.md +54 -0
  91. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/access.schema.json +50 -0
  92. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/connections.schema.json +23 -0
  93. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/data-rooms.schema.json +74 -0
  94. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/manifest.schema.json +170 -0
  95. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/template-header.schema.json +25 -0
  96. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +26 -6
  97. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/template_check.py +491 -0
  98. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/CHANGELOG.md +7 -0
  99. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/TEMPLATE.md +347 -0
  100. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/access.json +24 -0
  101. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/assembly.md +15 -0
  102. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/checks/acceptance.md +17 -0
  103. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/connections.json +29 -0
  104. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/data-rooms.json +9 -0
  105. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/manifest.json +32 -0
  106. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
  107. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +21 -21
  108. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +4 -4
  109. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/cleanup.md +30 -0
  110. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +2 -1
  111. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +3 -2
  112. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/remote-sessions.md +75 -0
  113. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +28 -13
  114. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/task-context.md +58 -0
  115. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/waits.md +36 -0
  116. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +14 -0
  117. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +143 -36
  118. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +136 -15
  119. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +78 -3
  120. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +9 -2
@@ -0,0 +1,912 @@
1
+ """The source manifest's rules (DEC-0149, `fabric-source/1`, FAC-SEM-156 to FAC-SEM-160), ported from the contract's
2
+ `src/source-rules.ts` at the pin and held to its vectors (`test/fixtures/contract/source-vectors.json`, FAA-261).
3
+
4
+ - `source_tree_problems(value)`: the tree check over `{manifest, files, instanceStrings?, listed?}`, each place the
5
+ instance shows through as `{kind, file?, line?, column?, entry?, include?, exclude?}`, never the text it found;
6
+ - `patch_paths_allowed(manifest, paths)`: the paths of a patch that are not source (DEC-0149 item 13);
7
+ - `manifest_findings(manifest)`: FAC-SEM-156 (slots) and FAC-SEM-157 (paths), each `{code, instancePath, message}`;
8
+ - `package_findings(source, package)`: FAC-SEM-160, the package's `secrets[]` and `state[]` against the slots;
9
+ - `package_members(manifest)`: the `secrets[]` and `state[]` a packer writes from the manifest.
10
+
11
+ Columns count UTF-16 code units, as the contract's do; the credential and home-folder shapes are fabric_source_check's
12
+ (the contract ported them from it). Standard library only.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ from pathlib import Path
19
+ import re
20
+ import sys
21
+ import unicodedata
22
+ from typing import Any, Callable, Dict, List, Optional, Set, Tuple
23
+ from urllib.parse import unquote
24
+
25
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
26
+ import fabric_secrets as _secrets # noqa: E402 - the contract's credential detector (DEC-0044), beside this file
27
+ import fabric_source_check as _check # noqa: E402 - the credential and home-folder shapes this port shares
28
+
29
+ SOURCE_FILE = "fabric.source.json"
30
+ SOURCE_FORMAT = "fabric-source/1"
31
+ SLOT_KINDS = ("secret", "config", "store", "endpoint")
32
+ _GRAMMAR_HEAD = r"(?![/~])(?![\s\S]*\\)(?![\s\S]*//)(?!(?:[\s\S]*/)?\.{1,2}(?:/|\Z))"
33
+ SOURCE_FILE_PATH = re.compile(_GRAMMAR_HEAD + r"[^\u0000-\u001f:]{1,256}(?<!/)\Z")
34
+ SOURCE_PATH = re.compile(_GRAMMAR_HEAD + r"[^\u0000-\u001f:]{1,256}\Z")
35
+ _HFS_IGNORABLE = re.compile("[‌-‏‪-‮-]")
36
+ _NUSKHURI = re.compile("[ⴀ-ⴥ]")
37
+ _ASCII_LOWER = str.maketrans("ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")
38
+ BLOCKING_KINDS = ("home-path", "credential", "binary", "nested-repository", "instance-string", "missing",
39
+ "short-instance-string", "no-include")
40
+ WARNING_KINDS = ("email", "link-outside", "no-license", "unused-exclude", "excluded-by-fold")
41
+ _JS_DOT = "[^\n\r

]"
42
+ CREDENTIAL_FILE = re.compile(r"(?:\.env(?:\.[^\n]+)?|[^\n]*\.(?:pem|key|p12|pfx|jks|keystore)|id_(?:rsa|dsa|ecdsa|ed25519)"
43
+ r"|\.(?:npmrc|netrc|pypirc))\Z")
44
+ CREDENTIAL_FILE_OK = re.compile(r"\.env\.(?:example|sample|template)\Z")
45
+ TOKEN_FILE = re.compile(r"[^\n]+\.(?:token|credential)\Z")
46
+ _SHORT_NAME = re.compile("~[0-9]")
47
+
48
+
49
+ def _u16(text: str) -> int:
50
+ """A text's length in UTF-16 code units, as the contract counts it."""
51
+ return len(text) + sum(1 for c in text if ord(c) > 0xFFFF)
52
+
53
+
54
+ def _strings(value: Any) -> List[Tuple[str, int]]:
55
+ return [(item, index) for index, item in enumerate(value) if isinstance(item, str)] if isinstance(value, list) else []
56
+
57
+
58
+ def _objects(value: Any) -> List[Tuple[Dict[str, Any], int]]:
59
+ return [(item, index) for index, item in enumerate(value) if isinstance(item, dict)] if isinstance(value, list) else []
60
+
61
+
62
+ def _nfc(text: str) -> str:
63
+ return unicodedata.normalize("NFC", text)
64
+
65
+
66
+ def _ascii_fold(text: str) -> str:
67
+ return text.translate(_ASCII_LOWER)
68
+
69
+
70
+ def is_dot_git_segment(segment: str) -> bool:
71
+ """`.git` as Git's is_hfs_dotgit and is_ntfs_dotgit read a segment (or the short name `git~1`)."""
72
+ name = _ascii_fold(_HFS_IGNORABLE.sub("", segment)).rstrip(" .")
73
+ return name in (".git", "git~1")
74
+
75
+
76
+ def _in_git(path: str) -> bool:
77
+ return any(is_dot_git_segment(s) for s in path.split("/"))
78
+
79
+
80
+ def fold_segment(segment: str) -> str:
81
+ """One segment as a case-insensitive file system may resolve it: canonical caseless matching, Georgian Nuskhuri onto
82
+ Mkhedruli, trailing dots and spaces dropped (`foldSegment`)."""
83
+ text = unicodedata.normalize("NFD", _HFS_IGNORABLE.sub("", segment)).lower().upper().lower()
84
+ text = _NUSKHURI.sub(lambda m: chr(ord(m.group(0)) - 0x2D00 + 0x10D0), _nfc(text))
85
+ return text.rstrip(" .")
86
+
87
+
88
+ def fold_path(path: str) -> str:
89
+ return "/".join(fold_segment(s) for s in path.split("/"))
90
+
91
+
92
+ def in_grammar(path: str, folder: bool = False) -> bool:
93
+ """In the grammar as written, in NFC and in NFD, with no segment that folds to nothing (`inGrammar`)."""
94
+ grammar = SOURCE_PATH if folder else SOURCE_FILE_PATH
95
+ if not all(grammar.match(p) for p in (path, _nfc(path), unicodedata.normalize("NFD", path))):
96
+ return False
97
+ body = path[:-1] if path.endswith("/") else path
98
+ return all(fold_segment(s) != "" for s in body.split("/"))
99
+
100
+
101
+ def _folder_prefixes(path: str) -> List[str]:
102
+ return [path[:at + 1] for at, c in enumerate(path) if c == "/"]
103
+
104
+
105
+ class _PathList:
106
+ def __init__(self, entries: List[str], reaches: bool = False, everything: bool = False) -> None:
107
+ self.files = {e for e in entries if not e.endswith("/")}
108
+ self.folders = {e for e in entries if e.endswith("/")}
109
+ self.reaches = {p for e in entries for p in _folder_prefixes(e)} if reaches else set()
110
+ self.all = everything
111
+
112
+
113
+ def _empty_segment(entry: str) -> Tuple[List[str], int, bool]:
114
+ written = _nfc(entry)
115
+ bare = not written.endswith("/")
116
+ segments = (written if bare else written[:-1]).split("/")
117
+ at = next((i for i, s in enumerate(segments) if fold_segment(s) == ""), -1)
118
+ return segments, at, bare
119
+
120
+
121
+ def _widest_reading(entry: str) -> Tuple[bool, bool, List[str]]:
122
+ """An exclude entry outside the grammar as a file system reads it removes its widest reading (`widestReading`)."""
123
+ segments, at, bare = _empty_segment(entry)
124
+ if at < 0:
125
+ return False, bare, [fold_path(_nfc(entry))]
126
+ depth = shallowest = at
127
+ for segment in segments[at:]:
128
+ if fold_segment(segment) != "":
129
+ depth += 1
130
+ elif segment.count(".") >= 2:
131
+ depth -= 1
132
+ shallowest = min(shallowest, depth)
133
+ if shallowest <= 0:
134
+ return True, bare, []
135
+ folder = "/".join(fold_segment(s) for s in segments[:shallowest])
136
+ return False, bare, ([folder + "/", folder] if bare else [folder + "/"])
137
+
138
+
139
+ def _folded_list(value: Any, widest: bool = False) -> _PathList:
140
+ entries: List[str] = []
141
+ everything = False
142
+ for entry, _ in _strings(value):
143
+ if in_grammar(entry, True):
144
+ entries.append(fold_path(_nfc(entry)))
145
+ elif widest and SOURCE_PATH.match(entry):
146
+ whole, _bare, more = _widest_reading(entry)
147
+ everything = everything or whole
148
+ entries += more
149
+ return _PathList(entries, everything=everything)
150
+
151
+
152
+ def _path_list(value: Any, reaches: bool = False) -> _PathList:
153
+ return _PathList([_nfc(e) for e, _ in _strings(value) if in_grammar(e, True)], reaches)
154
+
155
+
156
+ def _excludes_folder(lst: _PathList, folded: str) -> bool:
157
+ return lst.all or any(p in lst.folders or p[:-1] in lst.files for p in _folder_prefixes(folded))
158
+
159
+
160
+ def _covers_nfc(lst: _PathList, path: str) -> bool:
161
+ return path in lst.files or any(f in lst.folders for f in _folder_prefixes(path))
162
+
163
+
164
+ def _list_covers(lst: _PathList, written: str) -> bool:
165
+ return _u16(written) <= 512 and _covers_nfc(lst, _nfc(written))
166
+
167
+
168
+ def _exclude_covers_nfc(lst: _PathList, path: str) -> bool:
169
+ return lst.all or path in lst.files or any(f in lst.folders or f[:-1] in lst.files for f in _folder_prefixes(path))
170
+
171
+
172
+ def _folded_covers(lst: _PathList, written: str) -> bool:
173
+ return _u16(written) <= 512 and _exclude_covers_nfc(lst, fold_path(_nfc(written)))
174
+
175
+
176
+ def source_path_test(manifest: Any) -> Callable[[Any], bool]:
177
+ """Whether a path is source: in the grammar, in no `.git`, the manifest itself or inside an include path and no
178
+ exclude path (exclude read folded)."""
179
+ source = manifest if isinstance(manifest, dict) else {}
180
+ include = _path_list(source.get("include"))
181
+ exclude = _folded_list(source.get("exclude"), True)
182
+
183
+ def test(path: Any) -> bool:
184
+ if not isinstance(path, str) or not in_grammar(path) or _in_git(path):
185
+ return False
186
+ if _nfc(path) == SOURCE_FILE:
187
+ return True
188
+ return _list_covers(include, path) and not _folded_covers(exclude, path)
189
+ return test
190
+
191
+
192
+ def fold_only_exclude_test(manifest: Any) -> Callable[[Any], int]:
193
+ """The first exclude entry that names a path only once both are folded, or -1 (`foldOnlyExcludeTest`)."""
194
+ source = manifest if isinstance(manifest, dict) else {}
195
+ include = _path_list(source.get("include"))
196
+ literal = _path_list(source.get("exclude"))
197
+ first: Dict[str, int] = {}
198
+ for entry, at in _strings(source.get("exclude")):
199
+ if in_grammar(entry, True):
200
+ first.setdefault(fold_path(_nfc(entry)), at)
201
+
202
+ def test(path: Any) -> int:
203
+ if not isinstance(path, str) or not in_grammar(path) or _in_git(path) or _nfc(path) == SOURCE_FILE:
204
+ return -1
205
+ if not _list_covers(include, path) or (_u16(path) <= 512 and _exclude_covers_nfc(literal, _nfc(path))):
206
+ return -1
207
+ folded = fold_path(_nfc(path))
208
+ keys = [folded] + [k for f in _folder_prefixes(folded) for k in (f, f[:-1])]
209
+ found = [first[k] for k in keys if k in first]
210
+ return min(found) if found else -1
211
+ return test
212
+
213
+
214
+ def excluded_folder_test(manifest: Any) -> Callable[[str], bool]:
215
+ """Whether a walk skips a folder (no trailing `/`): an exclude names it or a folder holding it, and no include
216
+ path lies under it (`excludedFolderTest`)."""
217
+ source = manifest if isinstance(manifest, dict) else {}
218
+ exclude = _folded_list(source.get("exclude"), True)
219
+ under = {p for e, _ in _strings(source.get("include")) if in_grammar(e, True) for p in _folder_prefixes(_nfc(e))}
220
+ return lambda written: _excludes_folder(exclude, fold_path(_nfc(written)) + "/") and (_nfc(written) + "/") not in under
221
+
222
+
223
+ def _unchecked_paths(manifest: Any) -> List[Tuple[str, int]]:
224
+ if not isinstance(manifest, dict):
225
+ return []
226
+ return [(e["path"], i) for e, i in _objects(manifest.get("unchecked")) if isinstance(e.get("path"), str)]
227
+
228
+
229
+ def unchecked_test(manifest: Any) -> Callable[[Any], bool]:
230
+ is_source = source_path_test(manifest)
231
+ unchecked = _path_list([p for p, _ in _unchecked_paths(manifest)])
232
+ return lambda path: is_source(path) and _nfc(path) != SOURCE_FILE and _list_covers(unchecked, path)
233
+
234
+
235
+ def at_manifest_path(written: Any) -> bool:
236
+ return isinstance(written, str) and _nfc(written[:-1] if written.endswith("/") else written) == SOURCE_FILE
237
+
238
+
239
+ def nested_test(manifest: Any) -> Callable[[Any], bool]:
240
+ """Whether a nested repository at a path lies in the source (`nestedTest`)."""
241
+ source = manifest if isinstance(manifest, dict) else {}
242
+ include = _path_list(source.get("include"), True)
243
+ exclude = _folded_list(source.get("exclude"), True)
244
+
245
+ def test(written: Any) -> bool:
246
+ stored = written[:-1] if isinstance(written, str) and written.endswith("/") else written
247
+ if not isinstance(stored, str) or not in_grammar(stored) or _in_git(stored):
248
+ return False
249
+ if at_manifest_path(written):
250
+ return True
251
+ path = _nfc(stored)
252
+ folder = path + "/"
253
+ reached = path in include.files or folder in include.reaches or any(p in include.folders for p in _folder_prefixes(folder))
254
+ folded = fold_path(path)
255
+ return reached and folded not in exclude.files and not _excludes_folder(exclude, folded + "/")
256
+ return test
257
+
258
+
259
+ def credential_file_name(name: str) -> str:
260
+ return _ascii_fold(_HFS_IGNORABLE.sub("", name)).rstrip(" .")
261
+
262
+
263
+ def credential_file(path: str) -> bool:
264
+ """A file that is a credential by its name, read as a case-insensitive or Windows file system resolves it."""
265
+ written = path[path.rfind("/") + 1:]
266
+ name = credential_file_name(written)
267
+ return bool((CREDENTIAL_FILE.match(name) and not CREDENTIAL_FILE_OK.match(written)) or TOKEN_FILE.match(name))
268
+
269
+
270
+ def _credential_file_folded(path: str) -> bool:
271
+ written = path[path.rfind("/") + 1:]
272
+ name = fold_segment(written)
273
+ return bool((CREDENTIAL_FILE.match(name) and not CREDENTIAL_FILE_OK.match(written)) or TOKEN_FILE.match(name))
274
+
275
+
276
+ def patch_paths_allowed(manifest: Any, paths: Any) -> List[str]:
277
+ """DEC-0149 item 13: the paths of a patch that are not source, in the order given (empty: the patch applies)."""
278
+ is_source = source_path_test(manifest)
279
+ source = manifest if isinstance(manifest, dict) else {}
280
+ any_exclude = bool(_strings(source.get("exclude")))
281
+
282
+ def refused(path: Any) -> bool:
283
+ if not is_source(path):
284
+ return True
285
+ folded = fold_path(_nfc(path))
286
+ return (folded == SOURCE_FILE or credential_file(path) or _credential_file_folded(path)
287
+ or (any_exclude and any(_SHORT_NAME.search(s) for s in folded.split("/"))))
288
+ if not isinstance(paths, list):
289
+ return ["paths"]
290
+ return [(p if isinstance(p, str) else "paths[%d]" % i) for i, p in enumerate(paths) if refused(p)]
291
+
292
+
293
+ # #region credential and home shapes: the contract's credentialAt and homeAt, read with the source check's shapes
294
+ _PUBLIC_SIGNATURE = re.compile(r"(\"(?:sig|signature|signatures)\"[ \t]*:[ \t]*\")([A-Za-z0-9_-]{86})(\")", re.IGNORECASE)
295
+ _URL_IN_LINE = re.compile("[A-Za-z][A-Za-z0-9+.-]{0,63}://[^\t\n\x0b\x0c\r \x1c-\x1f\x85\xa0  - "
296
+ "

   \"'<>`]+")
297
+
298
+
299
+ def _secret_urls(line: str) -> List[Tuple[int, int]]:
300
+ return [m.span() for m in _URL_IN_LINE.finditer(line) if _check._url_secret(m.group(0))] if "://" in line else []
301
+
302
+
303
+ def _unescaped(line: str) -> Optional[Tuple[str, List[int]]]:
304
+ if "\\" not in line:
305
+ return None
306
+ text, origin = _check._unescaped_with_origin(line)
307
+ return (text, [a for a, _ in origin]) if text != line else None
308
+
309
+
310
+ def _credential_in(written: str) -> int:
311
+ line = _PUBLIC_SIGNATURE.sub(lambda m: m.group(1) + " " * len(m.group(2)) + m.group(3), written) if '"' in written else written
312
+ found: List[int] = []
313
+ block = _check.PRIVATE_KEY.search(line)
314
+ if block:
315
+ found.append(block.start())
316
+ found += [m.start() for m in _check.JWK_PRIVATE.finditer(line) if _check._real(m.group(1))]
317
+ found += [a for a, b in _jwt_spans(line) if _check._real(line[a:b])]
318
+ found += [a for a, _ in _check._provider_keys(line)]
319
+ found += [m.start() for m in _check._overlapping(_secrets._BEARER_INSIDE, line) if _check._real(m.group(0))]
320
+ found += [a for a, _ in _secret_urls(line)][:1]
321
+ for m in (_check._assigned(line) if ":" in line or "=" in line else ()):
322
+ name, value = m.group(1), m.group(2)
323
+ if _check._credential(name, value, _check._quoted(line, m)) and _secrets._long_secret(unquote(value)) \
324
+ and _check._real(unquote(value)):
325
+ found.append(m.start(2))
326
+ return min(found) if found else -1
327
+
328
+
329
+ def _jwt_spans(text: str) -> List[Tuple[int, int]]:
330
+ """`jwtSpans`: `JWT_SHAPE`'s leftmost, non-overlapping matches, read as the contract's one pass reads them."""
331
+ spans = []
332
+ run = re.compile(r"[A-Za-z0-9_-]*")
333
+ at = text.find("eyJ")
334
+ while at >= 0:
335
+ one = run.match(text, at + 3).end()
336
+ start_next = one
337
+ if one - at - 3 >= 2 and text[one:one + 1] == ".":
338
+ two = run.match(text, one + 1).end()
339
+ if two - one - 1 >= 2 and text[two:two + 1] == ".":
340
+ three = run.match(text, two + 1).end()
341
+ spans.append((at, three))
342
+ start_next = three
343
+ at = text.find("eyJ", start_next)
344
+ return spans
345
+
346
+
347
+ def credential_at(line: str) -> int:
348
+ """The index where a line first shows a credential, as written or with its JSON escapes undone, or -1."""
349
+ written = _credential_in(line)
350
+ if written >= 0:
351
+ return written
352
+ unescaped = _unescaped(line)
353
+ if unescaped is None:
354
+ return -1
355
+ found = _credential_in(unescaped[0])
356
+ return -1 if found < 0 else unescaped[1][found]
357
+
358
+
359
+ def _credential_url_spans(line: str) -> List[Tuple[int, int]]:
360
+ written = _secret_urls(line)
361
+ unescaped = _unescaped(line)
362
+ if written or unescaped is None:
363
+ return written
364
+ text, origin = unescaped
365
+ return [(origin[a], origin[b] if b < len(origin) else len(line)) for a, b in _secret_urls(text)]
366
+
367
+
368
+ def _own_secret_value(text: str) -> bool:
369
+ return bool(_secrets.JWT_SHAPE.search(text) or _check.URL_SECRET_VALUE.search(text) or _secrets._long_secret(text))
370
+
371
+
372
+ def credential_shaped(text: str) -> bool:
373
+ return credential_at(text) >= 0 or _own_secret_value(text)
374
+
375
+
376
+ def _prose_secret_value(text: str) -> bool:
377
+ return bool(_secrets.JWT_SHAPE.search(text) or any(True for _ in _check._provider_keys(text))
378
+ or _secrets._BEARER_INSIDE.search(text) or _secrets._long_secret(text))
379
+
380
+
381
+ def home_at(line: str) -> int:
382
+ found = [m.start(1) for pattern in _check.HOME_PATHS for m in pattern.finditer(line)
383
+ if len(m.group(1)) > 1 and m.group(1).lower() not in _check.NEUTRAL_HOMES]
384
+ return min(found) if found else -1
385
+ # #endregion
386
+
387
+
388
+ def _forms(entry: str) -> List["re.Pattern[str]"]:
389
+ """An instance string as written and in NFC, each also percent-encoded, form-encoded, JSON-escaped, with `\\/`, and
390
+ with every character outside ASCII as `\\uXXXX`; matched without regard to case."""
391
+ out: List[str] = []
392
+ for written in (entry, _nfc(entry)):
393
+ js = json.dumps(written, ensure_ascii=False)[1:-1]
394
+ ascii_form = "".join(c if ord(c) < 0x80 else "".join("\\u%04x" % u for u in _units(c)) for c in js)
395
+ percent = "".join(c if re.match(r"[A-Za-z0-9\-_.!~*'()]", c) else "".join("%%%02X" % b for b in c.encode("utf-8"))
396
+ for c in written)
397
+ for form in (written, percent, percent.replace("%20", "+"), js, js.replace("/", "\\/"), ascii_form):
398
+ if form not in out:
399
+ out.append(form)
400
+ return [re.compile(re.escape(f), re.IGNORECASE) for f in out]
401
+
402
+
403
+ def _units(c: str) -> List[int]:
404
+ data = c.encode("utf-16-be", "surrogatepass")
405
+ return [int.from_bytes(data[i:i + 2], "big") for i in range(0, len(data), 2)]
406
+
407
+
408
+ def _first_of(patterns: List["re.Pattern[str]"], text: str) -> Tuple[int, str]:
409
+ for form in (text, _nfc(text)):
410
+ found = [m.start() for m in (p.search(form) for p in patterns) if m]
411
+ if found:
412
+ return min(found), form
413
+ return -1, text
414
+
415
+
416
+ _LOCAL_CHAR = re.compile(r"[A-Za-z0-9._%+-]")
417
+ _DOMAIN = re.compile(r"[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}")
418
+
419
+
420
+ def _addresses(line: str) -> List[Tuple[int, str]]:
421
+ found, floor = [], 0
422
+ at = line.find("@")
423
+ while at >= 0:
424
+ start = at
425
+ while start > floor and at - start < 64 and _LOCAL_CHAR.match(line[start - 1]):
426
+ start -= 1
427
+ if start != at:
428
+ domain = _DOMAIN.match(line, at + 1)
429
+ if domain:
430
+ floor = domain.end()
431
+ found.append((start, line[start:floor]))
432
+ at = line.find("@", at + 1)
433
+ return found
434
+
435
+
436
+ def _example_address(address: str) -> bool:
437
+ domain = address[address.rfind("@") + 1:].lower()
438
+ return (any(domain == n or domain.endswith("." + n) for n in ("example.com", "example.org", "example.net"))
439
+ or domain.endswith((".invalid", ".test", ".example")))
440
+
441
+
442
+ def _image_name(text: str) -> bool:
443
+ return text[text.rfind(".") + 1:].lower() in ("png", "jpg", "jpeg", "gif", "svg", "webp", "ico")
444
+
445
+
446
+ def _not_an_address(line: str, start: int, text: str) -> bool:
447
+ return line[start + len(text):start + len(text) + 1] == ":" or _image_name(text)
448
+
449
+
450
+ _MARKDOWN = re.compile(r"\.(?:md|mdx|markdown)\Z", re.IGNORECASE | re.ASCII)
451
+ _JS_SPACE = "[" + _secrets.JS_SPACE + "]"
452
+ _LINK = re.compile(r"\]\(%s{0,64}<?([^)\]%s>]{1,2048})>?(?:%s{1,64}[^)\]]{0,2048})?\)"
453
+ % (_JS_SPACE, _secrets.JS_SPACE, _JS_SPACE))
454
+ _SCHEME = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*:")
455
+ _LICENSE_FILE = re.compile(r"(?:LICENSE|LICENCE|COPYING)(?:\.%s+)?\Z" % _JS_DOT, re.IGNORECASE | re.ASCII)
456
+
457
+
458
+ def _decode_uri_component(raw: str) -> str:
459
+ """`decodeURIComponent`, or the text as written when it would throw."""
460
+ if re.search(r"%(?![0-9A-Fa-f]{2})", raw):
461
+ return raw
462
+ try:
463
+ return unquote(raw, errors="strict")
464
+ except UnicodeDecodeError:
465
+ return raw
466
+
467
+
468
+ def _resolve(parts: List[str], raw: str) -> Optional[str]:
469
+ for segment in raw.split("/"):
470
+ if segment in ("", "."):
471
+ continue
472
+ if segment == "..":
473
+ if not parts:
474
+ return None
475
+ parts.pop()
476
+ else:
477
+ parts.append(segment)
478
+ return "/".join(parts)
479
+
480
+
481
+ def _resolve_link(file: str, target: str) -> Optional[str]:
482
+ raw = _decode_uri_component(re.sub(r"[#?][\s\S]*\Z", "", target, count=1))
483
+ return _resolve([] if raw.startswith("/") else file.split("/")[:-1], raw)
484
+
485
+
486
+ def source_tree_problems(value: Any) -> List[Dict[str, Any]]:
487
+ """DEC-0149 item 8, the tree check (`sourceTreeProblems`)."""
488
+ data = value if isinstance(value, dict) else {}
489
+ manifest = data.get("manifest") if isinstance(data.get("manifest"), dict) else {}
490
+ problems: List[Dict[str, Any]] = []
491
+ instance: List[Tuple[int, List["re.Pattern[str]"]]] = []
492
+ if isinstance(data.get("instanceStrings"), list):
493
+ for index, entry in enumerate(data["instanceStrings"]):
494
+ if not isinstance(entry, str) or len(entry) < 3 or any(0xD800 <= ord(c) <= 0xDFFF for c in entry):
495
+ problems.append({"kind": "short-instance-string", "entry": index})
496
+ else:
497
+ instance.append((index, _forms(entry)))
498
+ if not _strings(manifest.get("include")):
499
+ problems.append({"kind": "no-include"})
500
+ allowed = {a.lower() for a, _ in _strings(manifest.get("publicAddresses"))}
501
+ binaries = {_nfc(p) for p, _ in _strings(manifest.get("binaries"))}
502
+ is_source, is_nested, is_unchecked = source_path_test(manifest), nested_test(manifest), unchecked_test(manifest)
503
+ given = data.get("files") if isinstance(data.get("files"), list) else []
504
+ files = [(f, i) for i, f in enumerate(given) if isinstance(f, dict)
505
+ and (is_nested(f.get("path")) if f.get("nestedRepository") is True else is_source(f.get("path")))]
506
+ source_paths = [f["path"] for f, _ in files if f.get("nestedRepository") is not True]
507
+ fold_only = fold_only_exclude_test(manifest)
508
+ folded_out = []
509
+ for index, f in enumerate(given):
510
+ if isinstance(f, dict) and f.get("nestedRepository") is not True:
511
+ at = fold_only(f.get("path"))
512
+ if at >= 0:
513
+ folded_out.append({"kind": "excluded-by-fold", "file": index, "exclude": at})
514
+ source_folders = {"/".join(parts[:k + 1]) for p in source_paths for parts in [_nfc(p).split("/")[:-1]] for k in range(len(parts))}
515
+
516
+ def link_inside(target: str) -> bool:
517
+ return target == "" or is_source(target) or _nfc(target) in source_folders
518
+
519
+ for f, index in files:
520
+ path = f["path"]
521
+ binary = f.get("binary") is True and _nfc(path) != SOURCE_FILE
522
+ if home_at(path) >= 0:
523
+ problems.append({"kind": "home-path", "file": index})
524
+ if credential_file(path) or credential_at(path) >= 0:
525
+ problems.append({"kind": "credential", "file": index})
526
+ for entry, patterns in instance:
527
+ if _first_of(patterns, path)[0] >= 0:
528
+ problems.append({"kind": "instance-string", "file": index, "entry": entry})
529
+ if f.get("nestedRepository") is True:
530
+ problems.append({"kind": "nested-repository", "file": index})
531
+ continue
532
+ if is_unchecked(path):
533
+ continue
534
+ if binary:
535
+ if _nfc(path) not in binaries:
536
+ problems.append({"kind": "binary", "file": index})
537
+ continue
538
+ text = f["text"] if isinstance(f.get("text"), str) else ""
539
+ link_outside = False
540
+ if f.get("link") is True:
541
+ resolved = None if text.startswith("/") else _resolve(path.split("/")[:-1], text)
542
+ link_outside = resolved is None or not link_inside(resolved)
543
+ markdown = bool(_MARKDOWN.search(path))
544
+ for at, raw in enumerate(text.split("\n")):
545
+ line = raw[:-1] if raw.endswith("\r") else raw
546
+
547
+ def place(kind: str, column: int, within: str = line, **extra: Any) -> None:
548
+ problems.append(dict({"kind": kind, "file": index, "line": at + 1, "column": _u16(within[:column]) + 1}, **extra))
549
+ home = home_at(line)
550
+ if home >= 0:
551
+ place("home-path", home)
552
+ credential = credential_at(line)
553
+ if credential >= 0:
554
+ place("credential", credential)
555
+ for entry, patterns in instance:
556
+ found, form = _first_of(patterns, line)
557
+ if found >= 0:
558
+ place("instance-string", found, form, entry=entry)
559
+ urls = _credential_url_spans(line) if credential >= 0 else []
560
+ address = next((s for s, t in _addresses(line) if t.lower() not in allowed and not _example_address(t)
561
+ and not _not_an_address(line, s, t) and not any(a <= s < b for a, b in urls)), None)
562
+ if address is not None:
563
+ place("email", address)
564
+ if markdown:
565
+ outside = None
566
+ for m in _LINK.finditer(line):
567
+ target = m.group(1)
568
+ if target.startswith("#") or target.startswith("//") or _SCHEME.match(target):
569
+ continue
570
+ resolved = _resolve_link(path, target)
571
+ if resolved is None or not link_inside(resolved):
572
+ outside = m.start()
573
+ break
574
+ if outside is not None:
575
+ place("link-outside", outside)
576
+ if at == 0:
577
+ link_outside = False
578
+ if at == 0 and link_outside:
579
+ place("link-outside", 0)
580
+ problems += folded_out
581
+ present = {_nfc(p) for p in source_paths}
582
+ present_folders = {f for p in present for f in _folder_prefixes(p)}
583
+ for written, at in _strings(manifest.get("include")):
584
+ if not in_grammar(written, True):
585
+ continue
586
+ entry = _nfc(written)
587
+ if not (entry in present_folders if entry.endswith("/") else entry in present):
588
+ problems.append({"kind": "missing", "include": at})
589
+ if isinstance(data.get("listed"), list):
590
+ listed = [fold_path(_nfc(p)) for p, _ in _strings(data["listed"]) if _u16(p) <= 513]
591
+ listed_files = set(listed)
592
+ listed_folders = {f for p in listed for f in _folder_prefixes(p)}
593
+ naming = {p["exclude"] for p in folded_out}
594
+ for written, at in _strings(manifest.get("exclude")):
595
+ if not in_grammar(written, True):
596
+ continue
597
+ entry = fold_path(_nfc(written))
598
+ used = entry in listed_folders if entry.endswith("/") else (entry in listed_files or entry + "/" in listed_folders)
599
+ if at not in naming and not used:
600
+ problems.append({"kind": "unused-exclude", "exclude": at})
601
+ linked = {f["path"] for f, _ in files if f.get("link") is True}
602
+ if not any("/" not in p and _LICENSE_FILE.match(p) and p not in linked for p in source_paths):
603
+ problems.append({"kind": "no-license"})
604
+ return problems
605
+
606
+
607
+ # #region the manifest and the package (FAC-SEM-156, FAC-SEM-157, FAC-SEM-160)
608
+ def _slot_findings(value: Dict[str, Any]) -> List[Dict[str, str]]:
609
+ findings: List[Dict[str, str]] = []
610
+
611
+ def flag(at: str, message: str) -> None:
612
+ findings.append({"code": "FAC-SEM-156", "instancePath": at, "message": message})
613
+ if isinstance(value.get("description"), str) and _prose_secret_value(value["description"]):
614
+ flag("/description", "a manifest never holds a value: this looks like a credential")
615
+ names: List[Any] = []
616
+ stores: List[Any] = []
617
+ for slot, index in _objects(value.get("slots")):
618
+ if slot.get("name") in names:
619
+ flag("/slots/%d/name" % index, "slots[%d] is declared twice" % index)
620
+ names.append(slot.get("name"))
621
+ for field in ("name", "purpose"):
622
+ text = slot.get(field)
623
+ if isinstance(text, str) and (_secrets.secret_value(text) or credential_shaped(text)
624
+ or credential_shaped(json.dumps(text, ensure_ascii=False))):
625
+ flag("/slots/%d/%s" % (index, field), "a slot is named, never filled: this looks like a credential")
626
+ if slot.get("kind") == "store" and isinstance(slot.get("path"), str):
627
+ if slot["path"] in stores:
628
+ flag("/slots/%d/path" % index, "slots[%d].path is declared twice" % index)
629
+ stores.append(slot["path"])
630
+ return findings
631
+
632
+
633
+ def _grammar_finding(member: str, path: str, index: int, at: str = "") -> List[Dict[str, str]]:
634
+ if not SOURCE_PATH.match(path) or in_grammar(path, True):
635
+ return []
636
+ effect = "names, reaches and removes nothing" if member != "exclude" else "still removes its widest reading"
637
+ return [{"code": "FAC-SEM-157", "instancePath": "%s/%s/%d" % (at, member, index),
638
+ "message": "%s[%d] is outside the path grammar as a file system reads it, so it %s" % (member, index, effect)}]
639
+
640
+
641
+ def _path_findings(value: Dict[str, Any]) -> List[Dict[str, str]]:
642
+ findings: List[Dict[str, str]] = []
643
+
644
+ def flag(at: str, message: str) -> None:
645
+ findings.append({"code": "FAC-SEM-157", "instancePath": at, "message": message})
646
+ include = _strings(value.get("include"))
647
+ for member in ("include", "exclude", "binaries"):
648
+ seen: Set[str] = set()
649
+ for path, index in _strings(value.get(member)):
650
+ if path in seen:
651
+ flag("/%s/%d" % (member, index), "%s[%d] is listed twice" % (member, index))
652
+ seen.add(path)
653
+ if _in_git(path):
654
+ flag("/%s/%d" % (member, index), "%s[%d] lies in .git, which is never source" % (member, index))
655
+ findings.extend(_grammar_finding(member, path, index))
656
+ includes = _path_list(value.get("include"))
657
+ folded_includes = _folded_list(value.get("include"))
658
+ excludes = _folded_list(value.get("exclude"), True)
659
+ is_source = source_path_test(value)
660
+
661
+ def prefixes_of(written: str) -> List[str]:
662
+ return _folder_prefixes(fold_path(_nfc(written))) if in_grammar(written, True) else []
663
+ widened = _folded_list([e for e, _ in _strings(value.get("exclude")) if not in_grammar(e, True)], True)
664
+ for written, index in include:
665
+ path = fold_path(_nfc(written))
666
+ dead = (excludes.all or any(f != path and (f in excludes.folders or f[:-1] in excludes.files) for f in prefixes_of(written))
667
+ or path in widened.files or any(f in widened.folders or f[:-1] in widened.files for f in prefixes_of(written)))
668
+ if dead:
669
+ flag("/include/%d" % index, "include[%d] lies inside an exclude path, so it names nothing" % index)
670
+ folded_include_paths = [fold_path(_nfc(p)) for p, _ in include if in_grammar(p, True)]
671
+ for path, index in _strings(value.get("exclude")):
672
+ if not in_grammar(path, True):
673
+ continue
674
+ folded = fold_path(_nfc(path))
675
+ if folded in folded_includes.files or folded in folded_includes.folders or folded + "/" in folded_includes.folders:
676
+ flag("/exclude/%d" % index, "exclude[%d] is an include path: it would remove it whole, so leave both out" % index)
677
+ elif not any(f in folded_includes.folders for f in prefixes_of(path)) and not any(
678
+ i.startswith(folded if folded.endswith("/") else folded + "/") for i in folded_include_paths):
679
+ flag("/exclude/%d" % index, "exclude[%d] lies inside no include folder, so it removes nothing" % index)
680
+ for path, index in _strings(value.get("binaries")):
681
+ if not in_grammar(path, True):
682
+ continue
683
+ if fold_path(_nfc(path)) == SOURCE_FILE:
684
+ flag("/binaries/%d" % index, "binaries[%d] names %s, which is always read as text" % (index, SOURCE_FILE))
685
+ continue
686
+ if not _in_git(path) and not is_source(path):
687
+ flag("/binaries/%d" % index, "binaries[%d] is not a file of the source" % index)
688
+ unchecked: Set[str] = set()
689
+ for path, index in _unchecked_paths(value):
690
+ if path in unchecked:
691
+ flag("/unchecked/%d/path" % index, "unchecked[%d] is listed twice" % index)
692
+ unchecked.add(path)
693
+ if not path.endswith("/") and in_grammar(path) and fold_path(_nfc(path)) == SOURCE_FILE:
694
+ flag("/unchecked/%d/path" % index, "unchecked[%d] names %s, which is always read" % (index, SOURCE_FILE))
695
+ continue
696
+ inside = (in_grammar(path, True) and not _in_git(_nfc(path)[:-1])
697
+ and any(p in includes.folders for p in _folder_prefixes(_nfc(path)))
698
+ and not _excludes_folder(excludes, fold_path(_nfc(path)))) if path.endswith("/") else is_source(path)
699
+ if not inside:
700
+ flag("/unchecked/%d/path" % index, "unchecked[%d] is not in the source" % index)
701
+ return findings
702
+
703
+
704
+ def manifest_findings(manifest: Any) -> List[Dict[str, str]]:
705
+ """FAC-SEM-156 and FAC-SEM-157 over a source manifest (the contract's `source-manifest` rules)."""
706
+ return _slot_findings(manifest) + _path_findings(manifest) if isinstance(manifest, dict) else []
707
+
708
+
709
+ def package_findings(source: Any, package: Any) -> List[Dict[str, str]]:
710
+ """FAC-SEM-160: the package's secrets[] are the source's secret slots, by name and optional; its state[] paths the
711
+ store slots' paths, when state[] holds any entry."""
712
+ if not isinstance(source, dict) or not isinstance(package, dict):
713
+ return []
714
+ findings: List[Dict[str, str]] = []
715
+
716
+ def flag(at: str, message: str) -> None:
717
+ findings.append({"code": "FAC-SEM-160", "instancePath": at, "message": message})
718
+ slots = [s for s, _ in _objects(source.get("slots"))]
719
+ secrets = {s["name"]: s.get("optional") is True for s in slots if s.get("kind") == "secret" and isinstance(s.get("name"), str)}
720
+ stores = {s["path"] for s in slots if s.get("kind") == "store" and isinstance(s.get("path"), str)}
721
+ packaged = _objects(package.get("secrets"))
722
+ names = {e.get("name") for e, _ in packaged}
723
+ for name in secrets:
724
+ if name not in names:
725
+ flag("/package/secrets", "the source's secret slot %s is not in the package's secrets[]" % name)
726
+ for entry, index in packaged:
727
+ name = entry.get("name")
728
+ if not isinstance(name, str) or name not in secrets:
729
+ flag("/package/secrets/%d/name" % index, "secrets[%d] is no secret slot of the source" % index)
730
+ elif secrets[name] != (entry.get("optional") is True):
731
+ flag("/package/secrets/%d/optional" % index, "secrets[%d] is %soptional in the source" % (index, "" if secrets[name] else "not "))
732
+ state = package.get("state")
733
+ if not isinstance(state, list) or not state:
734
+ return findings
735
+ paths = {e.get("path") for e, _ in _objects(state)}
736
+ for path in sorted(stores):
737
+ if path not in paths:
738
+ flag("/package/state", "a store slot's path of the source is not in the package's state[]")
739
+ for entry, index in _objects(state):
740
+ if not isinstance(entry.get("path"), str) or entry["path"] not in stores:
741
+ flag("/package/state/%d/path" % index, "state[%d] is no store slot of the source" % index)
742
+ return findings
743
+
744
+
745
+ def package_members(manifest: Dict[str, Any]) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
746
+ """The `secrets[]` and `state[]` a packer writes from a source manifest: each secret slot by name, purpose and
747
+ optional, each store slot's path with its purpose (DEC-0149, the package's FAC-SEM-160)."""
748
+ secrets, state = [], []
749
+ for slot, _ in _objects(manifest.get("slots")):
750
+ if slot.get("kind") == "secret" and isinstance(slot.get("name"), str):
751
+ entry = {"name": slot["name"], "purpose": slot.get("purpose")}
752
+ if slot.get("optional") is True:
753
+ entry["optional"] = True
754
+ secrets.append(entry)
755
+ elif slot.get("kind") == "store" and isinstance(slot.get("path"), str):
756
+ state.append({"path": slot["path"], "purpose": slot.get("purpose")})
757
+ return secrets, state
758
+ # #endregion
759
+
760
+
761
+ # #region the folder check: `fabric_source_check.py check` on a tree whose root holds fabric.source.json
762
+ MESSAGES = {
763
+ "home-path": "a path in a person's home folder belongs to the instance, never to the source",
764
+ "credential": "shaped like a credential: a key reaches the agent through its slot, never through the source",
765
+ "binary": "a binary file the manifest does not list in binaries: it is source only when listed",
766
+ "nested-repository": "a nested repository: name it in exclude and check it in its own tree",
767
+ "instance-string": "a string of the owner's list: the installation's own strings never travel",
768
+ "missing": "an include path names no file of the tree",
769
+ "short-instance-string": "an instance string shorter than 3 characters is not looked for",
770
+ "no-include": "the source manifest names no include path, so nothing is source",
771
+ "email": "an e-mail address not listed in publicAddresses: a person's address belongs to the instance",
772
+ "link-outside": "a link to a path outside the source: a source export does not carry its target",
773
+ "no-license": "no licence file at the source's root (LICENSE, LICENCE or COPYING)",
774
+ "unused-exclude": "an exclude path names nothing the tree holds, in any letter case: check its spelling",
775
+ "excluded-by-fold": "an exclude path names this file only in another spelling: it is left out and never read",
776
+ }
777
+
778
+
779
+ def read_manifest(root: Path) -> Dict[str, Any]:
780
+ """The source manifest at a tree's root, refused (CheckError, exit 2) when it is no `fabric-source/1` object."""
781
+ path = root / SOURCE_FILE
782
+ if path.is_dir():
783
+ raise _check.CheckError("%s is a folder, not a file: the manifest cannot be read" % SOURCE_FILE)
784
+ try:
785
+ value = json.loads(path.read_bytes().decode("utf-8"))
786
+ except (OSError, UnicodeDecodeError, ValueError):
787
+ raise _check.CheckError("%s cannot be read as UTF-8 JSON" % SOURCE_FILE)
788
+ if not isinstance(value, dict) or value.get("format") != SOURCE_FORMAT:
789
+ raise _check.CheckError("%s is not a %s manifest (its format member)" % (SOURCE_FILE, SOURCE_FORMAT))
790
+ return value
791
+
792
+
793
+ def _nested_folders(root: Path, paths: List[str]) -> Set[str]:
794
+ """In a walk, the folders below the root that hold their own `.git`: each a nested repository."""
795
+ folders = {p.rsplit("/", 1)[0] for p in paths if "/" in p}
796
+ found = set()
797
+ for folder in sorted({f for p in folders for f in ["/".join(p.split("/")[:k + 1]) for k in range(p.count("/") + 1)]}):
798
+ if not any(folder.startswith(n + "/") for n in found) and (root / folder / ".git").exists():
799
+ found.add(folder)
800
+ return found
801
+
802
+
803
+ def check_folder(root: Path, listing: List[str], mode: str, entries: List[str], unreadable: List[str],
804
+ dependencies: List[str]) -> Dict[str, Any]:
805
+ """The tree check over the files a folder holds, under its fabric.source.json: findings with a column and a
806
+ severity, every path printed as `redact` prints it, never a value found."""
807
+ manifest = read_manifest(root)
808
+ seen = _check._Seen()
809
+ strings = [(n, e) for n, e in enumerate(entries, 1) if e and not e.startswith(_check.HOME_ENTRY)]
810
+ owner_homes = _check._owner_homes(entries)
811
+ nested = _nested_folders(root, listing) if mode == "walk" else set()
812
+ files: List[Dict[str, Any]] = []
813
+ not_read: List[Dict[str, str]] = [{"path": p, "why": "unreadable"} for p in unreadable]
814
+ is_source, unchecked = source_path_test(manifest), unchecked_test(manifest)
815
+ listed: List[str] = []
816
+ for folder in sorted(nested):
817
+ files.append({"path": folder, "nestedRepository": True})
818
+ listed.append(folder)
819
+ for path in listing:
820
+ if any(path.startswith(n + "/") for n in nested):
821
+ continue
822
+ listed.append(path)
823
+ full = root / path
824
+ entry: Dict[str, Any] = {"path": path}
825
+ try:
826
+ if full.is_symlink():
827
+ entry.update(link=True, text=os.readlink(str(full)))
828
+ elif full.is_dir():
829
+ entry["nestedRepository"] = True
830
+ listed.append(path + "/")
831
+ elif is_source(path):
832
+ data = full.read_bytes()
833
+ if b"\0" in data[:8192] and _nfc(path) != SOURCE_FILE:
834
+ entry["binary"] = True
835
+ else:
836
+ entry["text"] = data.decode("utf-8", "replace")
837
+ except OSError:
838
+ if is_source(path):
839
+ not_read.append({"path": path, "why": "unreadable"})
840
+ continue
841
+ files.append(entry)
842
+ problems = source_tree_problems({"manifest": manifest, "files": files, "listed": listed,
843
+ "instanceStrings": [e for _, e in strings]})
844
+ findings: List[Dict[str, Any]] = []
845
+ for f in manifest_findings(manifest):
846
+ findings.append({"path": SOURCE_FILE, "line": 0, "column": 0, "kind": "manifest", "severity": "blocking",
847
+ "rule": "%s at %s: %s" % (f["code"], f["instancePath"], f["message"])})
848
+ for p in problems:
849
+ path = files[p["file"]]["path"] if "file" in p else SOURCE_FILE
850
+ rule = MESSAGES[p["kind"]]
851
+ if "entry" in p:
852
+ rule += " (line %d of the owner's list)" % strings[p["entry"]][0]
853
+ for member in ("include", "exclude"):
854
+ if member in p:
855
+ rule += " (%s[%d])" % (member, p[member])
856
+ if "line" in p:
857
+ text = files[p["file"]].get("text", "").split("\n")[p["line"] - 1]
858
+ _check._text_findings(text.rstrip("\r"), entries, seen)
859
+ elif "file" in p:
860
+ _check._text_findings(path, entries, seen)
861
+ findings.append({"path": path, "line": p.get("line", 0), "column": p.get("column", 0), "kind": p["kind"],
862
+ "severity": "blocking" if p["kind"] in BLOCKING_KINDS else "warning", "rule": rule})
863
+ if owner_homes: # a declared home (`home: <name>`) is the owner's in every form, neutral or one letter
864
+ found = {(f["path"], f["line"]) for f in findings if f["kind"] == "home-path"}
865
+ for entry in files:
866
+ if "text" not in entry or not is_source(entry["path"]) or unchecked(entry["path"]):
867
+ continue
868
+ for number, line in enumerate(entry["text"].split("\n"), 1):
869
+ line = line[:-1] if line.endswith("\r") else line
870
+ spans = [a for a, b in _check._home_names(line, owner_homes) if line[a:b].lower() in owner_homes]
871
+ if spans and (entry["path"], number) not in found:
872
+ _check._text_findings(line, entries, seen)
873
+ findings.append({"path": entry["path"], "line": number, "column": _u16(line[:min(spans)]) + 1,
874
+ "kind": "home-path", "severity": "blocking", "rule": MESSAGES["home-path"]})
875
+ shown: Dict[str, str] = {} # one redaction per path, however many findings it has
876
+ for item in findings + not_read:
877
+ if item["path"] not in shown:
878
+ shown[item["path"]] = _check.redact(item["path"], entries, seen)
879
+ item["path"] = shown[item["path"]]
880
+ read = sum(1 for e in files if "text" in e and is_source(e["path"]))
881
+ source = sum(1 for e in files if e.get("nestedRepository") is not True and is_source(e["path"]))
882
+ return {"mode": mode, "manifest": True, "files": read, "source": source,
883
+ "skipped": {"binary": 0, "unreadable": len(not_read), "excluded": len(listing) - source, "nested": 0,
884
+ "dependencies": len(dependencies)},
885
+ "notRead": not_read, "findings": findings, "dependencies": [_check.redact(d, entries, seen) for d in dependencies]}
886
+
887
+
888
+ def print_run(result: Dict[str, Any], entries: List[str], listed_any: bool, as_json: bool, _print: Callable[[str], None]) -> int:
889
+ """The run under a source manifest: exit 1 on a blocking finding, 0 when only warnings remain and every source
890
+ file was read, 2 when one was not."""
891
+ findings = result["findings"]
892
+ blocking = [f for f in findings if f["severity"] == "blocking"] # type: ignore[union-attr,index]
893
+ complete = not result["notRead"]
894
+ result.update({"instanceStrings": sum(1 for e in entries if e and not e.startswith(_check.HOME_ENTRY)),
895
+ "ownerHomes": sum(1 for e in entries if e.startswith(_check.HOME_ENTRY)), "instanceList": listed_any,
896
+ "blocking": bool(blocking), "complete": complete})
897
+ if as_json:
898
+ _print(json.dumps(result, indent=2))
899
+ else:
900
+ for f in findings: # type: ignore[union-attr]
901
+ _print("%s:%s:%s: %s%s (%s)" % (f["path"], f["line"], f["column"], "" if f["severity"] == "blocking"
902
+ else "warning: ", f["kind"], f["rule"]))
903
+ for n in result["notRead"]: # type: ignore[union-attr]
904
+ _print("%s: not read (%s)" % (n["path"], n["why"]))
905
+ _print("fabric.source.json decides the source: %d source file(s), %d read as text (%s), %d blocking finding(s), "
906
+ "%d warning(s); %d path(s) not source; .fabric-source-ignore and --exclude are not read under a manifest"
907
+ % (result["source"], result["files"], "what Git would commit" if result["mode"] == "git" else "a walk",
908
+ len(blocking), len(findings) - len(blocking), result["skipped"]["excluded"])) # type: ignore
909
+ if blocking:
910
+ return 1
911
+ return 0 if complete else 2
912
+ # #endregion