@runeditrun/rer 0.1.1 → 0.2.1

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 (154) hide show
  1. package/README.md +113 -2
  2. package/cli/README.md +32 -5
  3. package/cli/contract.mjs +5 -5
  4. package/cli/deploy.mjs +17 -3
  5. package/cli/detached-server.mjs +51 -0
  6. package/cli/dev-identity.mjs +2 -1
  7. package/cli/door-mode.mjs +134 -0
  8. package/cli/door.mjs +263 -0
  9. package/cli/hosted-output.mjs +17 -7
  10. package/cli/hydration/browser.mjs +91 -0
  11. package/cli/hydration/cases.mjs +106 -0
  12. package/cli/hydration/check.mjs +105 -0
  13. package/cli/hydration/probe.mjs +164 -0
  14. package/cli/hydration/scripts.mjs +39 -0
  15. package/cli/hydration/verdict.mjs +85 -0
  16. package/cli/inspect.mjs +57 -10
  17. package/cli/lifecycle.mjs +36 -12
  18. package/cli/local.mjs +9 -3
  19. package/cli/main.mjs +3 -0
  20. package/cli/managed-server.mjs +62 -21
  21. package/cli/package-calls.mjs +81 -0
  22. package/cli/package-mode.mjs +46 -0
  23. package/cli/parse.mjs +15 -4
  24. package/cli/pnpm-runtime.mjs +96 -0
  25. package/cli/portability-worker.mjs +2 -2
  26. package/cli/release.mjs +1 -0
  27. package/cli/runtime-front-door.mjs +4 -2
  28. package/cli/server-proxy.mjs +69 -5
  29. package/cli/server-registry.mjs +10 -7
  30. package/cli/settings.mjs +14 -0
  31. package/cli/site-bindings.mjs +291 -0
  32. package/cli/site-browser-objects.mjs +36 -0
  33. package/cli/site-check.mjs +111 -0
  34. package/cli/site-client.mjs +50 -0
  35. package/cli/site-data.mjs +54 -0
  36. package/cli/site-entities.json +1 -0
  37. package/cli/site-input-assets.mjs +58 -0
  38. package/cli/site-inputs.mjs +79 -0
  39. package/cli/site-markup.mjs +102 -0
  40. package/cli/site-preview.mjs +53 -0
  41. package/cli/site-private.mjs +36 -0
  42. package/cli/site-references.mjs +93 -0
  43. package/cli/site-render.mjs +79 -0
  44. package/cli/site-syntax.mjs +21 -0
  45. package/cli/site.mjs +130 -0
  46. package/global/CHANGELOG.md +88 -0
  47. package/global/README.md +113 -2
  48. package/global/checkout.mjs +24 -10
  49. package/global/cli/install.mjs +6 -2
  50. package/global/delegate.mjs +9 -7
  51. package/global/door/command.mjs +254 -0
  52. package/global/door/control.mjs +65 -0
  53. package/global/door/cookies.mjs +41 -0
  54. package/global/door/pages.mjs +49 -0
  55. package/global/door/policy.mjs +49 -0
  56. package/global/door/routes.mjs +45 -0
  57. package/global/door/server.mjs +255 -0
  58. package/global/door/sites.mjs +192 -0
  59. package/global/launch/dev.mjs +21 -7
  60. package/global/launch/door.mjs +36 -0
  61. package/global/launch/install-pinned.mjs +22 -0
  62. package/global/launch/prepare.mjs +38 -5
  63. package/global/launch/render.mjs +6 -1
  64. package/global/launch/steps.mjs +12 -7
  65. package/global/launch/worker.mjs +3 -3
  66. package/global/list.mjs +6 -3
  67. package/global/main.mjs +39 -3
  68. package/global/output.mjs +12 -0
  69. package/global/rer.mjs +3 -0
  70. package/global/version.mjs +8 -8
  71. package/modules/dependencies/protocol.mjs +2 -1
  72. package/modules/deploy-kit/README.md +34 -1
  73. package/modules/deploy-kit/artifact.mjs +10 -0
  74. package/modules/deploy-kit/deploy.mjs +5 -2
  75. package/modules/deploy-kit/diagnostics.mjs +55 -0
  76. package/modules/deploy-kit/owner-cli.mjs +10 -4
  77. package/modules/deploy-kit/owner.mjs +8 -4
  78. package/modules/deploy-kit/prepare.mjs +3 -2
  79. package/modules/deploy-kit/provider.mjs +51 -9
  80. package/modules/deploy-kit/site.mjs +135 -0
  81. package/modules/erasure/CONTRACT.md +6 -3
  82. package/modules/erasure/README.md +8 -1
  83. package/modules/erasure/audit.mjs +2 -2
  84. package/modules/erasure/cascade.mjs +52 -11
  85. package/modules/erasure/order.mjs +29 -22
  86. package/modules/erasure/plan.mjs +4 -2
  87. package/modules/erasure/queue.mjs +41 -0
  88. package/modules/erasure/scope.mjs +3 -3
  89. package/modules/erasure/verification.mjs +4 -2
  90. package/modules/mail/README.md +1 -1
  91. package/modules/mail/index.mjs +1 -1
  92. package/modules/mail/src/core/errors.mjs +9 -0
  93. package/modules/mail/src/development/index.d.mts +6 -0
  94. package/modules/mail/src/development/index.mjs +6 -3
  95. package/modules/mail/src/development/viewer-html.mjs +3 -3
  96. package/modules/mail/src/development/viewer.mjs +8 -1
  97. package/modules/operator-auth/accounts/native.mjs +1 -1
  98. package/modules/operator-auth/dev/badge.mjs +4 -1
  99. package/modules/operator-auth/dev/index.mjs +15 -5
  100. package/modules/operator-auth/http.mjs +2 -3
  101. package/modules/operator-auth/index.d.mts +4 -9
  102. package/modules/operator-auth/loopback.mjs +17 -0
  103. package/modules/operator-auth/page.mjs +1 -1
  104. package/modules/operator-auth/service.mjs +1 -0
  105. package/modules/operator-auth/signin/index.d.mts +11 -0
  106. package/modules/operator-auth/signin/index.mjs +10 -7
  107. package/modules/owner-claim/verify.mjs +2 -1
  108. package/modules/portability/README.md +8 -0
  109. package/modules/portability/hash.mjs +6 -2
  110. package/modules/portability/import.mjs +1 -1
  111. package/modules/portability/legacy-files.mjs +4 -4
  112. package/modules/portability/legacy-safety.mjs +51 -10
  113. package/modules/portability/migration-history.mjs +15 -0
  114. package/modules/release/README.md +68 -3
  115. package/modules/release/keygen.mjs +18 -0
  116. package/modules/release/produce.mjs +14 -2
  117. package/modules/release/sign.mjs +44 -0
  118. package/modules/release/signature.mjs +336 -0
  119. package/modules/release/signing-cli.mjs +56 -0
  120. package/modules/release/signing-io.mjs +61 -0
  121. package/modules/release/signing-key.mjs +22 -0
  122. package/modules/release/source.mjs +15 -4
  123. package/modules/release/statement.mjs +80 -0
  124. package/modules/release/vectors/generate.mjs +381 -0
  125. package/modules/release/vectors/release-signature-vectors.json +2305 -0
  126. package/modules/release/verify.mjs +27 -0
  127. package/onboarding/adapter-auth.mjs +4 -3
  128. package/onboarding/adapter-settings.mjs +21 -5
  129. package/onboarding/adapter.mjs +2 -1
  130. package/onboarding/app-fetch.mjs +79 -0
  131. package/onboarding/archive-output.d.mts +4 -2
  132. package/onboarding/archive-output.mjs +22 -10
  133. package/onboarding/dependency-approval.mjs +4 -3
  134. package/onboarding/local/environment.mjs +7 -2
  135. package/onboarding/operator-install.mjs +8 -2
  136. package/onboarding/operator-prerequisites.mjs +5 -2
  137. package/onboarding/production-assets.mjs +23 -0
  138. package/onboarding/production-runtime.mjs +8 -4
  139. package/onboarding/queued-entry.mjs +9 -5
  140. package/operations/operations.mjs +11 -2
  141. package/package.json +76 -1
  142. package/scripts/publish-check/README.md +27 -17
  143. package/scripts/publish-check/content_identities.py +14 -233
  144. package/scripts/publish-check/git_history.py +2 -2
  145. package/scripts/publish-check/github_refs.py +28 -2
  146. package/scripts/publish-check/gitleaks.toml +1 -1
  147. package/scripts/publish-check/history_scan.py +6 -4
  148. package/scripts/publish-check/hygiene_config.py +3 -1
  149. package/scripts/publish-check/public_hygiene.py +63 -8
  150. package/scripts/publish-check/publication-config.json +16 -1
  151. package/scripts/publish-check/source_metadata.py +62 -0
  152. package/scripts/publish-check/source_shapes.py +91 -0
  153. package/scripts/publish-check/tree_scan.py +2 -2
  154. package/scripts/publish-check/source_hashes.mjs +0 -6
@@ -8,32 +8,42 @@ blob reads to system Git (`rev-parse`, `rev-list`, `ls-tree`, `cat-file --batch`
8
8
  its report private until translating it into the value-free v3 envelope.
9
9
  Hexadecimal credential assignments also use a Gitleaks rule without generic
10
10
  entropy or English stopword filters. Both assignment rules go through the same
11
- location-bound Source/module identity proof; provider and environment findings
11
+ location-bound Source/module identity classification; provider and environment findings
12
12
  remain independent.
13
13
  `tree_scan.py` applies the kept text and licence policy. `history_scan.py`
14
14
  assigns release, working-tree and historical scopes. The Node entry point bounds
15
15
  the whole operation and stops its private process group on timeout/cancellation.
16
16
 
17
+ Git message records carry their kind explicitly. Only commit records enable the
18
+ bounded public coauthor address rule described in the command guide; a file named
19
+ `@commit-message` has no special status. `public_hygiene.py` matches complete raw
20
+ `Co-Authored-By` lines in the final trailer paragraph case-insensitively and
21
+ masks only the accepted address for email inspection, leaving names, other
22
+ addresses and every other rule intact.
23
+
17
24
  Binary metadata decoding, Git protocol/object/ref modelling, frozen comparison,
18
25
  resumable inspection and their tests have been removed. Tests live in `tests.py`
19
26
  and `coverage_tests.py` and construct disposable repositories with actual Git.
20
27
 
21
- `content_identities.py` classifies only exact generic-api-key occurrences at
22
- proved Source key locations. It requires same-tree edition/lock selections,
23
- manifest and archive hashes, valid retained file records, and exact native match
24
- bytes. Retained archive bytes must hash to the maintained Source serializer's
25
- `hash(json(saved))` as well as the selected pin. `source_hashes.mjs` reuses that
26
- serializer. Duplicate JSON fields, non-JSON constants (including ignored fields),
27
- incorrect types, missing preimages, mismatched pins and unrecognized locations keep
28
- the native finding. Each selection's complete dependency closure must have valid
29
- archive and pin proofs before any location in that graph is allowed; unrelated
30
- coherent selections remain eligible. Normal origin repositories obey the maintained
31
- Source schema, including rejection of strings starting with `-`.
32
- Original/private keys and arbitrary copies of a known digest
33
- are not proofs. Provider and environment findings are unaffected. Native git/dir
34
- matches are held only in the private temporary report and memory for this proof;
35
- matched values are never added to the public report. This classification does not
36
- replace or relax the edition-authoring check.
28
+ `content_identities.py` binds generic-api-key and hex-api-key occurrences to
29
+ exact JSON value locations. `source_metadata.py` enumerates digest fields in the
30
+ formats emitted by `edition-authoring/cli.mjs` and `sources.mjs`: lock manifest,
31
+ toolchain, parent/baseline pins and file SHA-256 values; retained archive keys,
32
+ content digests and dependency pins; and the source origins' revision or
33
+ projection identities. These are format classifications independent of selected
34
+ graphs, canonical serialization, archive preimages and authoring freshness.
35
+ `source_shapes.py` first checks the documented local record types, immutable
36
+ origin identity, source selections and file/pin records. A malformed archive or
37
+ lock grants no identity roles; unknown extra fields never gain a role.
38
+ `provenance_pins.py` keeps the existing module revision classification.
39
+
40
+ `json_identity.py` rejects duplicate members and non-JSON constants and binds
41
+ native findings to exact string byte spans. Only documented structural fields
42
+ with the declared digest length qualify. An extra credential field, arbitrary
43
+ nested field or copy of the digest on the same minified line remains a finding.
44
+ Provider and known environment findings are independent. Native git/dir matches
45
+ stay in the private temporary report and memory; values are never added to the
46
+ public report. Authoring's integrity checks remain unchanged.
37
47
 
38
48
  `copy_identities.py` proves the maintained `base-copies.json` and portable
39
49
  `base/PROVENANCE.json` copy-inventory SHA-256 values
@@ -1,249 +1,30 @@
1
- """Classify module Git pins and prove Source keys at specific JSON locations.
2
-
3
- This is classification, not Source authoring validation. A stale lock still
4
- fails the authoring check. Missing original preimages remain ordinary findings.
5
- """
6
- import base64
7
- import hashlib
8
- import re
9
- import json
10
- from pathlib import Path
11
- import subprocess
12
- from json_identity import Document, HEX
1
+ """Classify documented JSON identities at exact native-match locations."""
2
+ from json_identity import Document
13
3
  from provenance_pins import module_pins
14
4
  from copy_identities import copy_pointers
15
-
16
- ARCHIVE = re.compile(r'\.edition/sources/([a-f0-9]{64})\.json\Z')
5
+ from source_metadata import ARCHIVE, source_pointers
17
6
 
18
7
 
19
8
  def candidate(path):
20
9
  return path in ('edition.json', 'sources.lock.json', 'base-copies.json', 'base/PROVENANCE.json') or bool(ARCHIVE.fullmatch(path))
21
10
 
22
11
 
23
- def safe_path(value):
24
- return (isinstance(value, str) and value and not re.search(r'[\\\x00-\x1f\x7f]', value)
25
- and all(part not in ('', '.', '..', '.git') for part in value.split('/')))
26
-
27
-
28
- def valid_files(files):
29
- if not isinstance(files, dict) or not files:
30
- return False
31
- for name, file in files.items():
32
- if (not safe_path(name) or not isinstance(file, dict) or not isinstance(file.get('data'), str)
33
- or type(file.get('executable')) is not bool):
34
- return False
35
- try:
36
- if base64.b64encode(base64.b64decode(file['data'], validate=True)).decode() != file['data']:
37
- return False
38
- except ValueError:
39
- return False
40
- return True
41
-
42
-
43
- def same_json(left, right):
44
- # Python equates booleans and numbers; Source JSON identities do not.
45
- return json.dumps(left, sort_keys=True) == json.dumps(right, sort_keys=True)
46
-
47
-
48
12
  class ContentIdentities:
49
13
  def __init__(self, files, read_regular=None):
50
- self.documents, self.allowed, self.archives, self.hashes = {}, {}, {}, {}
14
+ self.documents, self.allowed = {}, {}
51
15
  for path, data in files.items():
52
- if candidate(path):
53
- try:
54
- self.documents[path] = Document(data)
55
- except (ValueError, TypeError, IndexError, RecursionError, UnicodeError):
56
- pass
57
- if not self.documents:
58
- return
59
- for path, document in self.documents.items():
60
- for pointer in module_pins(path, document.value):
61
- self.allow(path, pointer)
62
- if path in ('base-copies.json', 'base/PROVENANCE.json') and read_regular is not None:
63
- for pointer in copy_pointers(document.value, read_regular):
64
- self.allow(path, pointer)
65
- recipe = self.document('edition.json')
66
- origins = [self.origin(recipe.get('parent'), True)]
67
- components = recipe.get('components', [])
68
- if isinstance(components, list):
69
- for component in components:
70
- source = component.get('source') if isinstance(component, dict) else None
71
- if isinstance(source, dict):
72
- origins.append(self.origin(source if source.get('kind') == 'git' else source.get('origin')))
73
- values = [recipe, *(v for v in origins if v is not None)]
74
- for path, document in self.documents.items():
75
- saved = document.value
76
- if ARCHIVE.fullmatch(path) and isinstance(saved, dict):
77
- values.extend([saved, saved.get('origin'), saved.get('files')])
78
- self.hashes = {}
79
- if values:
80
- try:
81
- result = subprocess.run(['node', str(Path(__file__).with_name('source_hashes.mjs'))],
82
- input=json.dumps(values).encode(), capture_output=True, timeout=60, check=True)
83
- hashes = json.loads(result.stdout)
84
- if len(hashes) != len(values) or not all(isinstance(h, str) and HEX.fullmatch(h) for h in hashes):
85
- return
86
- self.hashes = {json.dumps(v): h for v, h in zip(values, hashes)}
87
- except (OSError, ValueError, subprocess.SubprocessError):
88
- return
89
- # Authoring pins hash(json(saved)); a raw-byte pin alone cannot prove a
90
- # differently encoded retained archive, even when its fields agree.
91
- for path, document in self.documents.items():
92
- saved, match = document.value, ARCHIVE.fullmatch(path)
93
- if (match and isinstance(saved, dict) and saved.get('key') == match[1]
94
- and isinstance(saved.get('origin'), dict) and saved['origin']
95
- and valid_files(saved.get('files'))
96
- and isinstance(saved.get('dependencies', []), list)
97
- and saved['key'] == self.digest(saved['origin'])
98
- and saved.get('digest') == self.digest(saved['files'])
99
- and hashlib.sha256(document.data).hexdigest() == self.digest(saved)):
100
- self.archives[saved['key']] = (saved, hashlib.sha256(document.data).hexdigest())
101
- self.references()
102
-
103
- def digest(self, value):
104
- return self.hashes.get(json.dumps(value))
105
-
106
- def allow(self, path, pointer):
107
- self.allowed.setdefault(path, set()).add(pointer)
108
-
109
- def document(self, path):
110
- doc = self.documents.get(path)
111
- return doc.value if doc and isinstance(doc.value, dict) else {}
112
-
113
- def pin(self, pin, key):
114
- if not isinstance(pin, dict) or not isinstance(key, str) or key not in self.archives:
115
- return False
116
- archive, raw_sha = self.archives[key]
117
- return (pin.get('key') == key and pin.get('digest') == archive['digest']
118
- and pin.get('sha256') == raw_sha)
119
-
120
- def reference(self, ref, parent=False):
121
- if not isinstance(ref, dict):
122
- return None
123
- if not self.valid_selection(ref):
124
- return None
125
- selection = dict(path=ref.get('path') or '.', paths=ref.get('paths') or None,
126
- excludeSnapshots=True if parent else bool(ref.get('excludeSnapshots')))
127
- projection = ref.get('projection')
128
- if isinstance(projection, dict) and set(projection) == {'key', 'sha256'}:
129
- key = projection['key']
130
- if not isinstance(key, str) or key not in self.archives:
131
- return None
132
- saved, raw_sha = self.archives[key]
133
- if (projection['sha256'] == raw_sha and same_json(saved['origin'].get('selection'), selection)):
134
- return key
135
- return None
136
- origin = self.origin(ref, parent)
137
- key = self.digest(origin)
138
- if key in self.archives and same_json(self.archives[key][0]['origin'], origin):
139
- return key
140
- return None
141
-
142
- @staticmethod
143
- def valid_selection(ref):
144
- return (ref.get('path', '.') == '.' or safe_path(ref.get('path'))) and (
145
- ref.get('paths') is None or isinstance(ref['paths'], list) and bool(ref['paths'])
146
- and all(safe_path(p) for p in ref['paths'])) and (
147
- 'excludeSnapshots' not in ref or type(ref['excludeSnapshots']) is bool)
148
-
149
- @staticmethod
150
- def origin(ref, parent=False):
151
- if not isinstance(ref, dict) or not ContentIdentities.valid_selection(ref):
152
- return None
153
- projection = ref.get('projection')
154
- if (not isinstance(ref.get('repository'), str) or not ref['repository']
155
- or ref['repository'].startswith('-')):
156
- return None
157
- if 'projection' in ref:
158
- if ('revision' in ref or not isinstance(projection, dict) or set(projection) != {'schemaVersion', 'originalSha256', 'recipeSha256'}
159
- or type(projection['schemaVersion']) is not int or projection['schemaVersion'] != 1
160
- or not all(isinstance(projection[k], str) and HEX.fullmatch(projection[k])
161
- for k in ('originalSha256', 'recipeSha256'))):
162
- return None
163
- identity = dict(projection=projection)
164
- elif isinstance(ref.get('revision'), str) and re.fullmatch(r'[a-f0-9]{40}', ref['revision']):
165
- identity = dict(revision=ref['revision'])
166
- else:
167
- return None
168
- return dict(repository=ref['repository'], **identity, path=ref.get('path') or '.',
169
- paths=ref.get('paths') or None,
170
- excludeSnapshots=True if parent else bool(ref.get('excludeSnapshots')))
171
-
172
- def dependency_locations(self, key):
173
- # Stage the entire selected closure before granting any of its locations.
174
- # An unresolved later edge must not leave the root or earlier edges allowed.
175
- pending, visited, locations = [key], set(), []
176
- while pending:
177
- key = pending.pop()
178
- if key in visited:
16
+ if not candidate(path):
179
17
  continue
180
- visited.add(key)
181
- saved, _ = self.archives[key]
182
- path = '.edition/sources/' + key + '.json'
183
- locations.append((path, ('key',)))
184
- for i, pin in enumerate(saved.get('dependencies', [])):
185
- if not isinstance(pin, dict) or not self.pin(pin, pin.get('key')):
186
- return None
187
- locations.append((path, ('dependencies', i, 'key')))
188
- pending.append(pin['key'])
189
- return locations
190
-
191
- def references(self):
192
- recipe = self.document('edition.json')
193
- lock = self.document('sources.lock.json')
194
- if type(recipe.get('schemaVersion')) is not int or recipe['schemaVersion'] != 1 or not isinstance(recipe.get('components'), list):
195
- return
196
- components = lock.get('components')
197
- locked = (type(lock.get('schemaVersion')) is int and lock['schemaVersion'] == 1
198
- and isinstance(recipe.get('id'), str) and lock.get('edition') == recipe['id']
199
- and lock.get('manifest') == self.digest(recipe) and isinstance(components, list))
200
- if not locked:
201
- return
202
- def identifiers(entries):
203
- ids = [entry.get('id') for entry in entries if isinstance(entry, dict)]
204
- return (len(ids) == len(entries) and all(isinstance(i, str) and i for i in ids)
205
- and len(set(ids)) == len(ids))
206
- if not identifiers(recipe['components']) or not identifiers(components):
207
- return
208
- by_id = {c['id']: c for c in components}
209
- if (len(components) != len(recipe['components']) or any(
210
- c['id'] not in by_id or not same_json(c.get('source'), by_id[c['id']].get('source'))
211
- for c in recipe['components'])):
212
- return
213
- selected = []
214
- key = self.reference(recipe.get('parent'), parent=True)
215
- if key and locked and self.pin(lock.get('parent'), key):
216
- selected.append((key, [
217
- ('edition.json', ('parent', 'projection', 'key')),
218
- ('sources.lock.json', ('parent', 'key')),
219
- ]))
220
- for index, component in enumerate(recipe['components']):
221
- if not isinstance(component, dict):
222
- continue
223
- source = component.get('source')
224
- if not isinstance(source, dict):
225
- continue
226
- suffix = () if source.get('kind') == 'git' else ('origin',) if source.get('kind') == 'local' else None
227
- if suffix is None:
228
- continue
229
- key = self.reference(source if not suffix else source.get('origin'))
230
- if not key:
18
+ try:
19
+ document = Document(data)
20
+ except (ValueError, TypeError, IndexError, RecursionError, UnicodeError):
231
21
  continue
232
- for i, entry in enumerate(components):
233
- if (isinstance(entry, dict) and entry.get('id') == component.get('id')
234
- and same_json(entry.get('source'), source) and self.pin(entry.get('baseline'), key)):
235
- selected.append((key, [
236
- ('edition.json', ('components', index, 'source', *suffix, 'projection', 'key')),
237
- ('sources.lock.json', ('components', i, 'baseline', 'key')),
238
- ('sources.lock.json', ('components', i, 'source', *suffix, 'projection', 'key')),
239
- ]))
240
- # Validate each selection independently: unselected archives and unrelated
241
- # coherent selections cannot invalidate this graph's proof.
242
- for key, locations in selected:
243
- dependencies = self.dependency_locations(key)
244
- if dependencies is not None:
245
- for path, pointer in locations + dependencies:
246
- self.allow(path, pointer)
22
+ self.documents[path] = document
23
+ pointers = set(module_pins(path, document.value))
24
+ pointers.update(source_pointers(path, document.value))
25
+ if path in ('base-copies.json', 'base/PROVENANCE.json') and read_regular is not None:
26
+ pointers.update(copy_pointers(document.value, read_regular))
27
+ self.allowed[path] = pointers
247
28
 
248
29
  def matches(self, path, hit):
249
30
  document = self.documents.get(path)
@@ -96,12 +96,12 @@ class History:
96
96
  records = self.run('log', '-z', '--format=%H%x00%B', *self.revisions).split(b'\0')
97
97
  for index in range(0, len(records) - 1, 2):
98
98
  if records[index]:
99
- yield '@commit-message', records[index].decode().strip(), records[index + 1]
99
+ yield 'commit', records[index].decode().strip(), records[index + 1]
100
100
  for record in self.run('for-each-ref', '--format=%(objecttype) %(objectname)', 'refs/tags').splitlines():
101
101
  kind, oid = record.decode().split()
102
102
  if kind == 'tag':
103
103
  raw = self.run('cat-file', 'tag', oid)
104
- yield '@tag-message', oid, raw.partition(b'\n\n')[2]
104
+ yield 'tag', oid, raw.partition(b'\n\n')[2]
105
105
 
106
106
  def close(self):
107
107
  if self.reader:
@@ -51,6 +51,10 @@ CONTEXT_URL = re.compile(r'(?i)(?<![A-Za-z])(?:' + REPOSITORY_CONTEXT + r')'
51
51
  + r'''[`*_~\[\]\s"']*(?:''' + URL.pattern.replace('(?i)', '') + ')')
52
52
  LINK_URL = re.compile(r'(?i)\[(?P<label>[^]\n]+)\]\(\s*(?P<url>' + URL.pattern.replace('(?i)', '') + ')')
53
53
  REPOSITORY_LABEL = re.compile(r'(?i)\b(?:' + REPOSITORY_CONTEXT + r')\b')
54
+ # Words that make a nearby `owner/name` a repository rather than an identifier.
55
+ # A bare "source" is not one: "source commit/tree" is ordinary prose.
56
+ NEARBY_CONTEXT = re.compile(r'(?i)\b(?:github|repositor(?:y|ies)|repos?|forks?|forked|clones?|cloned|upstream|downstream|origin|submodules?)\b')
57
+ CONTEXT_WINDOW = 60
54
58
 
55
59
 
56
60
  def explicit_references(text, path):
@@ -76,6 +80,20 @@ class RepositoryObject(dict):
76
80
 
77
81
 
78
82
  def references(text, path, local_paths=frozenset(), project_identity=None):
83
+ stated, ambiguous = reference_sets(text, path, local_paths, project_identity)
84
+ return stated | ambiguous
85
+
86
+
87
+ def reference_sets(text, path, local_paths=frozenset(), project_identity=None):
88
+ """Split `owner/name` tokens into stated repositories and ambiguous ones.
89
+
90
+ A stated reference has GitHub context: a URL, a repository field, a labelled
91
+ or commanded mention, a repository route. An ambiguous token is a bare
92
+ word/word in prose (`commit/tree`, `rer-delegation/1`, `dmsans/v17`): it is
93
+ a repository only if one answers to that name privately, never because none
94
+ does.
95
+ """
96
+ ambiguous = set()
79
97
  repository_text = SERVICE_URL.sub('', text)
80
98
  found = {normalize(m.group(1)) for m in URL.finditer(repository_text)
81
99
  if not SPONSORS_URL.match(repository_text, m.start())}
@@ -106,8 +124,16 @@ def references(text, path, local_paths=frozenset(), project_identity=None):
106
124
  # A plain multi-segment path is ambiguous; GitHub's repository
107
125
  # routes, a labeled field or a GitHub command disambiguate it.
108
126
  labeled = explicit and (re.search(r'[:=|]', match['separator']) or re.match(r'(?i)github|gh\s', match['label']))
109
- if not suffix or REPOSITORY_ROUTE.match(suffix) or labeled:
127
+ if REPOSITORY_ROUTE.match(suffix) or labeled:
110
128
  found.add(normalize(repository))
129
+ elif not suffix:
130
+ if explicit:
131
+ weak = re.fullmatch(r'(?i)source', match['label']) and not re.search(r'(?i)repository|repo', markdown[match.start():match.start('reference')])
132
+ else:
133
+ line_start = markdown.rfind('\n', 0, match.start()) + 1
134
+ around = markdown[max(line_start, match.start() - CONTEXT_WINDOW):match.end() + CONTEXT_WINDOW].split('\n', 1)[0]
135
+ weak = not NEARBY_CONTEXT.search(around)
136
+ (ambiguous if weak else found).add(normalize(repository))
111
137
  elif METADATA.search(path) or SOURCE.search(path):
112
138
  if path.lower().endswith('.json'):
113
139
  try:
@@ -146,7 +172,7 @@ def references(text, path, local_paths=frozenset(), project_identity=None):
146
172
  found.update(normalize(m.group(1)) for m in TYPED_REPOSITORY.finditer(text))
147
173
  for declaration in declarations(text):
148
174
  found.update(normalize(m.group(1)) for m in DECLARED_SHORTHAND.finditer(declaration))
149
- return found
175
+ return found, ambiguous - found
150
176
 
151
177
 
152
178
  def local_dependencies(data, path):
@@ -29,7 +29,7 @@ regexes = ['''\.(?:test|fixture|fake|example|dummy|placeholder|synthetic)[_-][a-
29
29
 
30
30
  # SHA-shaped credentials must not disappear because their random bytes contain
31
31
  # an English stopword (for example dead/feed) or fall below generic entropy.
32
- # Source/module identities still require the location-bound proof in Python.
32
+ # Source/module identities still require exact structural JSON locations in Python.
33
33
  [[rules]]
34
34
  id = "hex-api-key"
35
35
  description = "Hexadecimal credential assignment"
@@ -128,7 +128,7 @@ def scan(repo, ref='HEAD', public_base=None, env_file=None, project=None, reposi
128
128
  if identities[identity_scope].matches(path, hit):
129
129
  continue
130
130
  else:
131
- # Content-identity documents keep their own stricter proof.
131
+ # Identity documents use their own exact structural locations.
132
132
  held = working_regular(Path(history.repo), path) if mode == 'dir' else read_regular(path)
133
133
  if held and checksums[identity_scope].matches(held[0], hit):
134
134
  continue
@@ -146,9 +146,11 @@ def scan(repo, ref='HEAD', public_base=None, env_file=None, project=None, reposi
146
146
  scope = 'working-tree' if path in working_paths else 'pre-existing' if commit in history.public_commits else 'newly-public'
147
147
  lines.append((count, count + length, path, commit, scope))
148
148
  count += length
149
- for path, commit, data in history.messages():
150
- report['history']['tagMessages' if path == '@tag-message' else 'commitMessages'] += 1
151
- rules, _ = text_rules(data, path, selected_context, resolver, hashes)
149
+ for message_type, commit, data in history.messages():
150
+ path = '@' + message_type + '-message'
151
+ report['history']['tagMessages' if message_type == 'tag' else 'commitMessages'] += 1
152
+ rules, _ = text_rules(data, path, selected_context, resolver, hashes,
153
+ commit_message=message_type == 'commit')
152
154
  scope = 'release-tree' if commit == history.commit else 'pre-existing' if commit in history.public_commits else 'newly-public'
153
155
  for rule in rules:
154
156
  add(rule, path, commit, scope=scope)
@@ -9,7 +9,9 @@ LOCAL_PATH = re.compile(r'''(?x)(?<![^\s("'`=:\[<>{|])(?:[*_]|~~)*(?:(?:(?i:(?a:
9
9
  CONFIG = '.publish-check/config.json'
10
10
  # An alphabetic top-level label ends before a following digit, such as the DER
11
11
  # tag after a certificate's email value; other labels keep the full extent.
12
- EMAIL = re.compile(r"(?<![A-Za-z0-9_.+%-])[A-Za-z0-9_][A-Za-z0-9_.+%'-]{0,63}@(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.(?:[A-Za-z]{2,63}(?![A-Za-z.-])|[A-Za-z][A-Za-z0-9-]{1,62}(?![A-Za-z0-9.-]))|(?<![A-Za-z0-9_.+%-])[A-Za-z0-9_][A-Za-z0-9_.+%'-]{0,63}@(?:example|test|invalid)(?![A-Za-z0-9.-])", re.I)
12
+ # A period is punctuation unless it starts another host label; never shorten
13
+ # a suffixed host into a reserved/public domain.
14
+ EMAIL = re.compile(r"(?<![A-Za-z0-9_.+%-])[A-Za-z0-9_][A-Za-z0-9_.+%'-]{0,63}@(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.(?:[A-Za-z]{2,63}(?![A-Za-z-]|\.[A-Za-z0-9-])|[A-Za-z][A-Za-z0-9-]{1,62}(?![A-Za-z0-9-]|\.[A-Za-z0-9-]))|(?<![A-Za-z0-9_.+%-])[A-Za-z0-9_][A-Za-z0-9_.+%'-]{0,63}@(?:example|test|invalid)(?![A-Za-z0-9-]|\.[A-Za-z0-9-])", re.I)
13
15
  RULES = {'hygiene.local-path', 'hygiene.github-private', 'hygiene.github-missing',
14
16
  'hygiene.email', 'hygiene.internal-reference'}
15
17
 
@@ -1,7 +1,7 @@
1
1
  """Public-tree and message hygiene; findings never include matched values."""
2
2
  from collections import Counter
3
3
  import re
4
- from github_refs import REPOSITORY_HOSTS, references
4
+ from github_refs import REPOSITORY_HOSTS, reference_sets
5
5
  from destination import RULE as PENDING_DESTINATION
6
6
  from hygiene_text import decoded_text
7
7
  from provenance import provenance_text
@@ -9,6 +9,29 @@ from hygiene_config import EMAIL, LOCAL_PATH, public_email
9
9
  from font_licence import without_required_notices
10
10
  from trust_bundle import anchor_values
11
11
  URI_AUTHORITY = re.compile(r'''(?i)(?a:(?P<scheme>[a-z][a-z0-9+.-]*:))?//(?P<authority>[^/?#\s<>"']+)''')
12
+ # Match raw, complete commit trailer lines; decoded or embedded prose cannot
13
+ # acquire trailer privileges. Only the address span is omitted from email checks.
14
+ COAUTHOR = re.compile(
15
+ rb'^Co-Authored-By:[ \t]+[^<>\r\n]*[^<>\s][ \t]+<(?P<address>'
16
+ rb'noreply@(?:anthropic|github)\.com|'
17
+ rb'[0-9]+\+[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}@users\.noreply\.github\.com'
18
+ rb')>[ \t]*\r?$', re.I | re.M)
19
+
20
+
21
+ def without_public_coauthor_addresses(data):
22
+ # A trailer block is the final paragraph and contains only trailer lines.
23
+ # A quoted attribution followed by ordinary prose stays ordinary body text.
24
+ tail = data.rstrip(b' \t\r\n')
25
+ block = re.split(rb'\r?\n[ \t]*\r?\n', tail)[-1]
26
+ if not all(re.fullmatch(rb'[A-Za-z0-9-]+:[ \t]+[^\r\n]+\r?', line)
27
+ for line in block.split(b'\n')):
28
+ return data
29
+ offset = len(tail) - len(block)
30
+ result = bytearray(data)
31
+ for match in COAUTHOR.finditer(block):
32
+ start, end = match.span('address')
33
+ result[offset + start:offset + end] = b' ' * (end - start)
34
+ return bytes(result)
12
35
 
13
36
 
14
37
  def email_userinfo(match):
@@ -25,12 +48,35 @@ def private_emails(text, config):
25
48
  # URL paths, queries and fragments remain visible to email inspection.
26
49
  email_text = URI_AUTHORITY.sub(email_userinfo, without_required_notices(text))
27
50
  email_text = re.sub(r'(?i)\b(?a:git@github\.com:)(?=[A-Za-z0-9])', '', email_text)
28
- version = r'(?:v?[0-9]+(?:\.[0-9]+)*(?:-[A-Za-z0-9-]+)?|[a-f0-9]{40})(?![A-Za-z0-9_.-])'
51
+ # A package manager's patch filename joins the name and version with an at sign.
52
+ version = r'(?:v?[0-9]+(?:\.[0-9]+)*(?:-[A-Za-z0-9-]+)?|[a-f0-9]{40})(?:\.patch)?(?![A-Za-z0-9_.-])'
29
53
  email_text = re.sub(r'(?m)^\s*-?\s*uses:\s*[\"\']?[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+@[^\s\"\']+', '', email_text)
30
54
  email_text = re.sub(r'(?<![A-Za-z0-9])(?:@[A-Za-z0-9_.-]+/)?[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)?@' + version, '', email_text)
31
55
  return [match.group() for match in EMAIL.finditer(email_text) if not public_email(match.group(), config)]
32
56
 
33
57
 
58
+ # A stored host name is lower case, upper case or capitalised, label by label.
59
+ # Bytes that merely decode to text, such as compressed pixels, rarely are.
60
+ HOST_LABEL = re.compile(r'(?:[a-z0-9-]+|[A-Z0-9-]+|[A-Z][a-z0-9-]*)')
61
+
62
+
63
+ # Control bytes that no text encoding uses (tab, newline, form feed and carriage
64
+ # return are text). Legacy 8-bit text such as latin-1 or Windows-1252 has none,
65
+ # while compressed or packed data is about one byte in nine.
66
+ NON_TEXT = re.compile(rb'[\x01-\x08\x0b\x0e-\x1f\x7f]')
67
+ BINARY_SHARE = 0.05
68
+
69
+
70
+ def binary(data):
71
+ # NULs are not counted: UTF-16 and UTF-32 text carries them between letters.
72
+ text = data.replace(b'\0', b'')
73
+ return bool(text) and len(NON_TEXT.findall(text)) / len(text) > BINARY_SHARE
74
+
75
+
76
+ def stored_host(address):
77
+ return all(HOST_LABEL.fullmatch(label) for label in address.rsplit('@', 1)[1].split('.'))
78
+
79
+
34
80
  def certificate_metadata(data, bundle, emails, config):
35
81
  # Every match lies inside a verified trust-anchor subject value: none
36
82
  # remains once those values are blanked, and each is one of theirs.
@@ -46,11 +92,11 @@ def certificate_metadata(data, bundle, emails, config):
46
92
  return not Counter(emails) - inside
47
93
 
48
94
 
49
- def inspect(data, path, config, resolver):
50
- return inspect_text(data, path, config, resolver)
95
+ def inspect(data, path, config, resolver, *, commit_message=False):
96
+ return inspect_text(data, path, config, resolver, commit_message=commit_message)
51
97
 
52
98
 
53
- def inspect_text(data, path, config, resolver):
99
+ def inspect_text(data, path, config, resolver, *, commit_message=False):
54
100
  # NUL removal covers UTF-16/32 ASCII content, matching the secret scanner.
55
101
  text = decoded_text(data)
56
102
  rules = set()
@@ -59,18 +105,27 @@ def inspect_text(data, path, config, resolver):
59
105
  if any(pattern.search(text) for pattern in config['internalReferencePatterns']):
60
106
  rules.add('hygiene.internal-reference')
61
107
  if '@' in text:
62
- emails = private_emails(text, config)
108
+ email_text = decoded_text(without_public_coauthor_addresses(data)) if commit_message else text
109
+ emails = private_emails(email_text, config)
63
110
  bundle = config.get('trustBundles', {}).get(path)
64
111
  if emails and not (bundle and certificate_metadata(data, bundle, emails, config)):
65
- rules.add('hygiene.email')
112
+ # Inside binary data a mailbox is judged by its own shape: a host
113
+ # name with mixed-case letters inside a label is not one.
114
+ if not binary(data) or any(stored_host(email) for email in emails):
115
+ rules.add('hygiene.email')
66
116
  ref_text, provenance = provenance_text(text, path, config.get('declaredSources', frozenset()))
67
- for repository in references(ref_text, path, config.get('localPaths', frozenset()), config.get('projectIdentity')):
117
+ stated, ambiguous = reference_sets(ref_text, path, config.get('localPaths', frozenset()), config.get('projectIdentity'))
118
+ for repository in sorted(stated | ambiguous):
68
119
  # A future source identity does not assert current GitHub visibility.
69
120
  # Explicit release destinations still take the strict visibility path.
70
121
  if (repository in config.get('intendedPublicRepositories', frozenset()) and
71
122
  repository != config.get('pendingDestination')):
72
123
  continue
73
124
  state = resolver.resolve(repository)
125
+ # A bare word/word with no GitHub context names a repository only when
126
+ # one exists privately; that no repository answers to it proves nothing.
127
+ if state == 'missing' and repository in ambiguous and repository != config.get('pendingDestination'):
128
+ continue
74
129
  if state == 'missing' and repository == config.get('pendingDestination'):
75
130
  rules.add(PENDING_DESTINATION)
76
131
  elif state in ('private', 'missing'):
@@ -7,6 +7,21 @@
7
7
  { "repository": "just-every/lamp", "reason": "Owner-declared public identity; created by clean-history export at the first public tag." },
8
8
  { "repository": "just-every/dial", "reason": "Owner-declared public identity; created by clean-history export at the first public tag." },
9
9
  { "repository": "just-every/orbit", "reason": "Owner-declared public identity; created by clean-history export at the first public tag." },
10
- { "repository": "just-every/uptime-monitor", "reason": "Owner-declared public identity; created by clean-history export at the first public tag." }
10
+ { "repository": "just-every/uptime-monitor", "reason": "Owner-declared public identity; created by clean-history export at the first public tag." },
11
+ { "repository": "just-every/run-slate", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
12
+ { "repository": "just-every/run-pencil", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
13
+ { "repository": "just-every/run-lamp", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
14
+ { "repository": "just-every/run-uptime", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
15
+ { "repository": "just-every/run-cradle", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
16
+ { "repository": "just-every/run-vector", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
17
+ { "repository": "just-every/run-mango", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
18
+ { "repository": "just-every/run-dial", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
19
+ { "repository": "just-every/run-penny", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
20
+ { "repository": "just-every/run-nook", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
21
+ { "repository": "just-every/run-orbit", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
22
+ { "repository": "just-every/run-oncall", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
23
+ { "repository": "just-every/run-spool", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
24
+ { "repository": "just-every/run-happytohelp", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
25
+ { "repository": "just-every/run-auth", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." }
11
26
  ]
12
27
  }