@mnstry/atelier 0.2.0-alpha.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 (260) hide show
  1. package/CHANGELOG.md +383 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +5 -0
  4. package/README.md +262 -0
  5. package/SECURITY.md +93 -0
  6. package/TRADEMARKS.md +88 -0
  7. package/announcements/2026-08-06-announcements-channel.v1.json +12 -0
  8. package/announcements/keys/mnstry-announcements.public.v1.json +9 -0
  9. package/bin/atelier.mjs +2 -0
  10. package/bin/mnstry-atelier.mjs +3 -0
  11. package/contracts/analysis-adapter.v1.schema.json +248 -0
  12. package/contracts/atelier-action-intent.v1.schema.json +143 -0
  13. package/contracts/atelier-analysis-adapter.v1.schema.json +360 -0
  14. package/contracts/atelier-attestation.v1.schema.json +239 -0
  15. package/contracts/atelier-boundary-policy.v1.schema.json +339 -0
  16. package/contracts/atelier-claim.v1.schema.json +97 -0
  17. package/contracts/atelier-export.v1.schema.json +1015 -0
  18. package/contracts/atelier-extension-pack.v1.schema.json +290 -0
  19. package/contracts/atelier-kit-manifest.v1.schema.json +273 -0
  20. package/contracts/atelier-lock.v1.schema.json +364 -0
  21. package/contracts/atelier-migration.v1.schema.json +162 -0
  22. package/contracts/atelier-project-config.v1.schema.json +352 -0
  23. package/contracts/atelier-readiness-protocol.v1.schema.json +517 -0
  24. package/contracts/atelier-readiness-run.v1.schema.json +229 -0
  25. package/contracts/atelier-readiness.v1.schema.json +457 -0
  26. package/contracts/compat-baseline.json +10 -0
  27. package/contracts/git-promote-event.v1.schema.json +145 -0
  28. package/contracts/knowledge-source-sidecar.v1.schema.json +157 -0
  29. package/contracts/mnstry-atelier-semantic-profile.v1.json +254 -0
  30. package/docs/announcements.md +101 -0
  31. package/docs/atelier-runtime.md +34 -0
  32. package/docs/attestation.md +201 -0
  33. package/docs/continuity.md +55 -0
  34. package/docs/contract-stability.md +111 -0
  35. package/docs/distributions.md +220 -0
  36. package/docs/install.md +140 -0
  37. package/docs/knowledge-graph.md +140 -0
  38. package/docs/ontology.md +206 -0
  39. package/docs/release-engineering.md +151 -0
  40. package/docs/repo-boundary-guard.md +199 -0
  41. package/docs/tenant-readiness.md +64 -0
  42. package/docs/upgrade.md +124 -0
  43. package/fixtures/analysis/invalid/native-graph.v1.json +4 -0
  44. package/fixtures/analysis/invalid/promoted-claim.v1.json +17 -0
  45. package/fixtures/analysis/invalid/runtime-import.v1.json +20 -0
  46. package/fixtures/analysis/valid/output.claims.v1.json +17 -0
  47. package/fixtures/analysis-adapter/invalid/manifest-hidden-provider.v1.json +24 -0
  48. package/fixtures/analysis-adapter/invalid/output-frontmatter-mutation.v1.json +21 -0
  49. package/fixtures/analysis-adapter/invalid/output-native-graph.v1.json +14 -0
  50. package/fixtures/analysis-adapter/manifest.disabled.v1.json +22 -0
  51. package/fixtures/analysis-adapter/manifest.enabled-local.v1.json +23 -0
  52. package/fixtures/analysis-adapter/output.claims.v1.json +21 -0
  53. package/fixtures/atelier-action-intent/invalid/runtime-mutation-authority.v1.json +16 -0
  54. package/fixtures/atelier-action-intent/invalid/unknown-top-level-field.v1.json +17 -0
  55. package/fixtures/atelier-action-intent/invalid/writes-beyond-clipboard.v1.json +16 -0
  56. package/fixtures/atelier-action-intent/valid/copy-agent-prompt.v1.json +16 -0
  57. package/fixtures/atelier-action-intent/valid/copy-repo-path.v1.json +22 -0
  58. package/fixtures/atelier-analysis-adapter/invalid/hidden-model-provider.v1.json +24 -0
  59. package/fixtures/atelier-analysis-adapter/invalid/inert-with-execute-command.v1.json +24 -0
  60. package/fixtures/atelier-analysis-adapter/invalid/proposals-with-runtime-import.v1.json +30 -0
  61. package/fixtures/atelier-analysis-adapter/valid/manifest-inert.v1.json +24 -0
  62. package/fixtures/atelier-analysis-adapter/valid/proposals-claim-only.v1.json +27 -0
  63. package/fixtures/atelier-attestation/invalid/bad-digest.v1.json +23 -0
  64. package/fixtures/atelier-attestation/invalid/conformance-scope.v1.json +23 -0
  65. package/fixtures/atelier-attestation/invalid/missing-signature.v1.json +22 -0
  66. package/fixtures/atelier-attestation/invalid/unknown-root-field.v1.json +24 -0
  67. package/fixtures/atelier-attestation/keys/roundtrip-issuer.public.v1.json +9 -0
  68. package/fixtures/atelier-attestation/valid/rejected-with-reasons.v1.json +49 -0
  69. package/fixtures/atelier-attestation/valid/signed-admitted.v1.json +35 -0
  70. package/fixtures/atelier-attestation/valid/signed-roundtrip.v1.json +29 -0
  71. package/fixtures/atelier-attestation/valid/unsigned-advisory.v1.json +23 -0
  72. package/fixtures/atelier-claim/invalid/unknown-provider.v1.json +15 -0
  73. package/fixtures/atelier-claim/invalid/unknown-top-level-field.v1.json +16 -0
  74. package/fixtures/atelier-claim/invalid/wrong-schema-const.v1.json +15 -0
  75. package/fixtures/atelier-claim/valid/adapter-style-proposed-relation.v1.json +19 -0
  76. package/fixtures/atelier-claim/valid/codex-proposed-relation.v1.json +16 -0
  77. package/fixtures/atelier-claim/valid/manual-proposed-relation.v1.json +16 -0
  78. package/fixtures/atelier-claim/valid/readiness-proposed-relation.v1.json +18 -0
  79. package/fixtures/atelier-export/invalid/dirty-unforced-empty-taint.v1.json +19 -0
  80. package/fixtures/atelier-export/invalid/object-local-audience-visibility.v1.json +11 -0
  81. package/fixtures/atelier-export/invalid/open-object-class-owner.v1.json +23 -0
  82. package/fixtures/atelier-export/invalid/pricing-legacy-amount.v1.json +23 -0
  83. package/fixtures/atelier-export/invalid/public-disguised-internal-source-ref.v1.json +11 -0
  84. package/fixtures/atelier-export/invalid/public-nested-private-source-ref.v1.json +17 -0
  85. package/fixtures/atelier-export/invalid/public-resolved-private-source-ref.v1.json +13 -0
  86. package/fixtures/atelier-export/invalid/public-resolved-sensitive-source-ref.v1.json +13 -0
  87. package/fixtures/atelier-export/invalid/public-source-ref-missing-provenance.v1.json +20 -0
  88. package/fixtures/atelier-export/invalid/public-source-team-kg.json +12 -0
  89. package/fixtures/atelier-export/invalid/source-node-legacy-visibility.v1.json +21 -0
  90. package/fixtures/atelier-export/invalid/source-node-unknown-field.v1.json +18 -0
  91. package/fixtures/atelier-export/invalid/unresolved-public-source-ref.v1.json +11 -0
  92. package/fixtures/atelier-export/sample-studio-offer.v1.json +397 -0
  93. package/fixtures/atelier-extension-pack/invalid/bundled-slug-collision.v1.json +22 -0
  94. package/fixtures/atelier-extension-pack/invalid/escapes-root.v1.json +22 -0
  95. package/fixtures/atelier-extension-pack/invalid/posture-tampered.v1.json +22 -0
  96. package/fixtures/atelier-extension-pack/invalid/protocols/autonomous-authority.v1.json +118 -0
  97. package/fixtures/atelier-extension-pack/invalid/protocols/offer-map.v1.json +109 -0
  98. package/fixtures/atelier-extension-pack/invalid/redefines-runtime-term.v1.json +19 -0
  99. package/fixtures/atelier-extension-pack/invalid/wrong-namespace.v1.json +19 -0
  100. package/fixtures/atelier-extension-pack/valid/protocols/contract-gate.v1.json +109 -0
  101. package/fixtures/atelier-extension-pack/valid/sample-pack.v1.json +48 -0
  102. package/fixtures/atelier-kit-manifest/invalid/runtime-copy-without-canonical-role.v1.json +53 -0
  103. package/fixtures/atelier-kit-manifest/invalid/telemetry-send-path.v1.json +21 -0
  104. package/fixtures/atelier-kit-manifest/valid/mnstry-atelier.valid.v1.json +76 -0
  105. package/fixtures/atelier-lock/invalid/absolute-contract-path.v1.json +40 -0
  106. package/fixtures/atelier-lock/invalid/runtime-mutation-enabled.v1.json +41 -0
  107. package/fixtures/atelier-lock/invalid/unknown-top-level-field.v1.json +42 -0
  108. package/fixtures/atelier-lock/valid/extension-pack.v1.json +55 -0
  109. package/fixtures/atelier-lock/valid/minimal.v1.json +44 -0
  110. package/fixtures/atelier-migration/invalid/live-apply-mode.v1.json +21 -0
  111. package/fixtures/atelier-migration/invalid/missing-review-evidence.v1.json +20 -0
  112. package/fixtures/atelier-migration/invalid/runtime-mutation-enabled.v1.json +21 -0
  113. package/fixtures/atelier-migration/valid/minimal.v1.json +32 -0
  114. package/fixtures/atelier-project-config/invalid/absolute-repo-path.v1.json +11 -0
  115. package/fixtures/atelier-project-config/invalid/ext-not-object.v1.json +12 -0
  116. package/fixtures/atelier-project-config/invalid/external-kind-read-boundary.v1.json +12 -0
  117. package/fixtures/atelier-project-config/invalid/missing-repos-and-alignment.v1.json +7 -0
  118. package/fixtures/atelier-project-config/invalid/unknown-top-level-field.v1.json +14 -0
  119. package/fixtures/atelier-project-config/valid/alignment-scaffold.v1.json +15 -0
  120. package/fixtures/atelier-project-config/valid/ext-extension-packs.v1.json +36 -0
  121. package/fixtures/atelier-project-config/valid/repo-workspace.v1.json +23 -0
  122. package/fixtures/atelier-project-config/validator-only/ext-member-not-object.v1.json +14 -0
  123. package/fixtures/atelier-readiness/invalid/dry-run-only-false.v1.json +87 -0
  124. package/fixtures/atelier-readiness/invalid/support-bundle-send-path.v1.json +87 -0
  125. package/fixtures/atelier-readiness/invalid/unknown-top-level-field.v1.json +88 -0
  126. package/fixtures/atelier-readiness/valid/ready-workspace.v1.json +84 -0
  127. package/fixtures/boundary-policy/invalid/content-rule-exception-blanket-path.v1.json +67 -0
  128. package/fixtures/boundary-policy/invalid/invalid-actor-private-repo-mapping.v1.json +20 -0
  129. package/fixtures/boundary-policy/invalid/legacy-warning-missing-warning.v1.json +20 -0
  130. package/fixtures/boundary-policy/invalid/missing-repo-coverage.v1.json +19 -0
  131. package/fixtures/boundary-policy/invalid/unknown-key.v1.json +12 -0
  132. package/fixtures/boundary-policy/valid/content-rule-exception.v1.json +67 -0
  133. package/fixtures/boundary-policy/valid/legacy-warning.v1.json +33 -0
  134. package/fixtures/boundary-policy/valid/strict-private-shared.v1.json +40 -0
  135. package/fixtures/git-promote/invalid/provision-named-revocable.v1.json +29 -0
  136. package/fixtures/git-promote/private-to-team.v1.json +29 -0
  137. package/fixtures/knowledge-source-sidecar/invalid/legacy-visibility-field.v1.json +17 -0
  138. package/fixtures/knowledge-source-sidecar/invalid/missing-audience.v1.json +15 -0
  139. package/fixtures/knowledge-source-sidecar/invalid/unknown-relation-kind.v1.json +18 -0
  140. package/fixtures/knowledge-source-sidecar/valid/html-source.v1.json +18 -0
  141. package/fixtures/knowledge-source-sidecar/valid/pdf-evidence.v1.json +18 -0
  142. package/fixtures/projects/neutral-project/atelier.project.json +15 -0
  143. package/fixtures/projects/neutral-project/ops/config/repo-access.v1.json +12 -0
  144. package/fixtures/projects/neutral-project/project-knowledge.graph.json +69 -0
  145. package/fixtures/projects/sample-workspace/atelier.project.json +23 -0
  146. package/fixtures/projects/sample-workspace/boundary-policy.v1.json +59 -0
  147. package/fixtures/projects/sample-workspace/content/README.md +16 -0
  148. package/fixtures/projects/sample-workspace/content/source.html +5 -0
  149. package/fixtures/projects/sample-workspace/content/source.html.kg.json +15 -0
  150. package/fixtures/projects/sample-workspace/repo-access.v1.json +9 -0
  151. package/fixtures/projects/source-formats-workspace/atelier.project.json +23 -0
  152. package/fixtures/projects/source-formats-workspace/content/README.md +17 -0
  153. package/fixtures/projects/source-formats-workspace/content/data.json +7 -0
  154. package/fixtures/projects/source-formats-workspace/content/data.json.kg.json +15 -0
  155. package/fixtures/projects/source-formats-workspace/content/logo.png.kg.json +15 -0
  156. package/fixtures/projects/source-formats-workspace/content/metrics.csv +3 -0
  157. package/fixtures/projects/source-formats-workspace/content/metrics.csv.kg.json +15 -0
  158. package/fixtures/projects/source-formats-workspace/content/pipeline.yaml +4 -0
  159. package/fixtures/projects/source-formats-workspace/content/pipeline.yaml.kg.json +15 -0
  160. package/fixtures/projects/source-formats-workspace/repo-access.v1.json +9 -0
  161. package/fixtures/readiness-protocols/extension-pack/invalid/protocol-missing-path.v1.json +20 -0
  162. package/fixtures/readiness-protocols/extension-pack/valid/with-protocol.v1.json +24 -0
  163. package/fixtures/readiness-protocols/protocol/invalid/missing-check-target.v1.json +103 -0
  164. package/fixtures/readiness-protocols/protocol/invalid/runtime-mutation-enabled.v1.json +107 -0
  165. package/fixtures/readiness-protocols/protocol/valid/minimal.v1.json +100 -0
  166. package/fixtures/readiness-protocols/run/invalid/missing-evidence.v1.json +37 -0
  167. package/fixtures/readiness-protocols/run/invalid/pass-with-blocker.v1.json +22 -0
  168. package/fixtures/readiness-protocols/run/invalid/runtime-mutation-enabled.v1.json +22 -0
  169. package/fixtures/readiness-protocols/run/valid/pass.v1.json +44 -0
  170. package/fixtures/support/invalid/leaky-support.v1.json +13 -0
  171. package/fixtures/support/valid/minimal-safe.v1.json +20 -0
  172. package/package.json +98 -0
  173. package/skills/claude/atelier-open/SKILL.md +68 -0
  174. package/skills/claude/mnstry-readiness/SKILL.md +41 -0
  175. package/skills/codex/atelier-open/SKILL.md +68 -0
  176. package/skills/codex/mnstry-readiness/SKILL.md +41 -0
  177. package/src/analysis/adapter.mjs +20 -0
  178. package/src/analysis/analysis-claim-contract.mjs +335 -0
  179. package/src/attestation/jcs.mjs +64 -0
  180. package/src/attestation/sign.mjs +305 -0
  181. package/src/boundary/content-rules.mjs +262 -0
  182. package/src/boundary/policy.mjs +806 -0
  183. package/src/check-atelier-export-contract.mjs +116 -0
  184. package/src/cli/run.mjs +283 -0
  185. package/src/collaboration/proposals.mjs +262 -0
  186. package/src/commands/analysis.mjs +8 -0
  187. package/src/commands/announcements.mjs +308 -0
  188. package/src/commands/attestation.mjs +198 -0
  189. package/src/commands/boundary.mjs +25 -0
  190. package/src/commands/config.mjs +4 -0
  191. package/src/commands/context.mjs +3 -0
  192. package/src/commands/distribution.mjs +224 -0
  193. package/src/commands/egress.mjs +11 -0
  194. package/src/commands/extension-pack.mjs +209 -0
  195. package/src/commands/feedback.mjs +3 -0
  196. package/src/commands/graph.mjs +3 -0
  197. package/src/commands/init.mjs +130 -0
  198. package/src/commands/lock.mjs +3 -0
  199. package/src/commands/project.mjs +3 -0
  200. package/src/commands/promote.mjs +4 -0
  201. package/src/commands/readiness.mjs +3 -0
  202. package/src/commands/server.mjs +6 -0
  203. package/src/commands/setup.mjs +250 -0
  204. package/src/commands/support.mjs +3 -0
  205. package/src/commands/upgrade.mjs +3 -0
  206. package/src/contracts/corpus.mjs +171 -0
  207. package/src/egress/check.mjs +45 -0
  208. package/src/egress/forbidden-egress.mjs +323 -0
  209. package/src/export/atelier-export-contract.mjs +722 -0
  210. package/src/extension-packs/loader.mjs +491 -0
  211. package/src/graph/graph.mjs +370 -0
  212. package/src/graph/knowledge-graph.mjs +887 -0
  213. package/src/harness/context-client.mjs +144 -0
  214. package/src/harness/context.mjs +27 -0
  215. package/src/index.mjs +88 -0
  216. package/src/project/config.mjs +557 -0
  217. package/src/project/file-class.mjs +113 -0
  218. package/src/project/git-ignore.mjs +31 -0
  219. package/src/project/path-match.mjs +30 -0
  220. package/src/project/repo-identity.mjs +176 -0
  221. package/src/projection/alignment-projection.mjs +169 -0
  222. package/src/projection/policy.mjs +90 -0
  223. package/src/projection/project.mjs +152 -0
  224. package/src/readiness/readiness.mjs +312 -0
  225. package/src/readiness-protocols/bundled-pack.mjs +766 -0
  226. package/src/readiness-protocols/runtime.mjs +419 -0
  227. package/src/server/local-sidecar.mjs +640 -0
  228. package/src/server/security.mjs +229 -0
  229. package/src/server/server.mjs +42 -0
  230. package/src/support/feedback-report.mjs +414 -0
  231. package/src/support/support-bundle.mjs +226 -0
  232. package/src/ui/html-primitives.mjs +104 -0
  233. package/src/upgrade/upgrade.mjs +701 -0
  234. package/src/validate-atelier-export-dry-run.mjs +75 -0
  235. package/templates/atelier.project.example.json +23 -0
  236. package/templates/distribution-workspace/.claude/launch.json +12 -0
  237. package/templates/distribution-workspace/README.md +86 -0
  238. package/templates/distribution-workspace/atelier.project.json +36 -0
  239. package/templates/distribution-workspace/boundary-policy.v1.json +61 -0
  240. package/templates/distribution-workspace/gitignore +29 -0
  241. package/templates/distribution-workspace/governance/repo-boundary-ledger.md +5 -0
  242. package/templates/distribution-workspace/project/README.md +19 -0
  243. package/templates/distribution-workspace/repo-access.v1.json +9 -0
  244. package/templates/launch.json +12 -0
  245. package/templates/private-domain-workspace/.claude/launch.json +12 -0
  246. package/templates/private-domain-workspace/README.md +50 -0
  247. package/templates/private-domain-workspace/atelier.project.json +28 -0
  248. package/templates/private-domain-workspace/boundary-policy.v1.json +54 -0
  249. package/templates/private-domain-workspace/domain/README.md +19 -0
  250. package/templates/private-domain-workspace/gitignore +29 -0
  251. package/templates/private-domain-workspace/governance/repo-boundary-ledger.md +4 -0
  252. package/templates/private-domain-workspace/repo-access.v1.json +9 -0
  253. package/templates/shared-project-workspace/.claude/launch.json +12 -0
  254. package/templates/shared-project-workspace/README.md +51 -0
  255. package/templates/shared-project-workspace/atelier.project.json +28 -0
  256. package/templates/shared-project-workspace/boundary-policy.v1.json +61 -0
  257. package/templates/shared-project-workspace/gitignore +29 -0
  258. package/templates/shared-project-workspace/governance/repo-boundary-ledger.md +5 -0
  259. package/templates/shared-project-workspace/project/README.md +19 -0
  260. package/templates/shared-project-workspace/repo-access.v1.json +9 -0
@@ -0,0 +1,64 @@
1
+ // RFC 8785 (JSON Canonicalization Scheme) serializer.
2
+ //
3
+ // Numbers and strings delegate to JSON.stringify: RFC 8785 defines both
4
+ // serializations as exactly ECMAScript's (ES2019+ well-formed stringify
5
+ // escapes lone surrogates, and number formatting follows Number::toString).
6
+ // The module's own work is recursive descent, UTF-16 code-unit key sorting
7
+ // (plain Array.prototype.sort over strings IS code-unit order), and strict
8
+ // rejection of every value JSON cannot faithfully round-trip.
9
+ //
10
+ // Rejections throw TypeError with a `JCS:` prefix naming the offending path,
11
+ // so a non-canonicalizable document fails loudly instead of silently dropping
12
+ // members (undefined), collapsing to {} (class instances, Map), or invoking
13
+ // toJSON surprises (Date).
14
+
15
+ function isPlainObject(value) {
16
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
17
+ const proto = Object.getPrototypeOf(value)
18
+ return proto === Object.prototype || proto === null
19
+ }
20
+
21
+ function reject(path, detail) {
22
+ throw new TypeError(`JCS: ${detail} at ${path}`)
23
+ }
24
+
25
+ function serialize(value, path) {
26
+ if (value === null) return 'null'
27
+ const kind = typeof value
28
+ if (kind === 'boolean') return value ? 'true' : 'false'
29
+ if (kind === 'number') {
30
+ if (!Number.isFinite(value)) reject(path, `non-finite number ${String(value)} is not representable in JSON`)
31
+ return JSON.stringify(value)
32
+ }
33
+ if (kind === 'string') return JSON.stringify(value)
34
+ if (kind === 'undefined') reject(path, 'undefined is not representable in JSON')
35
+ if (kind === 'bigint') reject(path, 'BigInt is not representable in JSON')
36
+ if (kind === 'function') reject(path, 'function is not representable in JSON')
37
+ if (kind === 'symbol') reject(path, 'symbol is not representable in JSON')
38
+ if (Array.isArray(value)) {
39
+ // Holes must fail loudly: Array.prototype.map skips them, which would emit
40
+ // invalid JSON like "[,]" instead of a faithful canonicalization.
41
+ const items = []
42
+ for (let index = 0; index < value.length; index += 1) {
43
+ const itemPath = `${path}[${index}]`
44
+ if (!(index in value)) reject(itemPath, 'sparse array hole is not representable in JSON')
45
+ items.push(serialize(value[index], itemPath))
46
+ }
47
+ return `[${items.join(',')}]`
48
+ }
49
+ if (!isPlainObject(value)) {
50
+ reject(path, 'non-plain object (Date, Map, class instance, ...) cannot be canonicalized')
51
+ }
52
+ const keys = Object.keys(value).sort()
53
+ const members = keys.map((key) => {
54
+ const memberPath = `${path}.${key}`
55
+ if (value[key] === undefined) reject(memberPath, 'undefined property value is not representable in JSON')
56
+ return `${JSON.stringify(key)}:${serialize(value[key], memberPath)}`
57
+ })
58
+ return `{${members.join(',')}}`
59
+ }
60
+
61
+ // Canonicalize a JSON-compatible value into its RFC 8785 string form.
62
+ export function canonicalize(value) {
63
+ return serialize(value, '$')
64
+ }
@@ -0,0 +1,305 @@
1
+ // Attestation signing and verification reference implementation.
2
+ //
3
+ // The normative signing input (docs/attestation.md, "Signing procedure"):
4
+ // the UTF-8 encoding of the RFC 8785 (JCS) canonicalization of the
5
+ // attestation document with its `signature` member set to null.
6
+ //
7
+ // Key handling is JWK-only — never PEM. es256 signatures use the 64-byte
8
+ // IEEE P1363 concatenation of r and s, not DER; the dsaEncoding flag below is
9
+ // load-bearing for interop and is asserted by tests (64 bytes / 86 base64url
10
+ // characters).
11
+ //
12
+ // Log-safety contract: every error and reason message in this module may name
13
+ // keyId and algorithm, and must never include any JWK member value or the
14
+ // signing input bytes.
15
+
16
+ import crypto from 'node:crypto'
17
+ import fs from 'node:fs'
18
+ import path from 'node:path'
19
+ import { fileURLToPath } from 'node:url'
20
+ import Ajv2020 from 'ajv/dist/2020.js'
21
+ import addFormats from 'ajv-formats'
22
+ import { canonicalize } from './jcs.mjs'
23
+
24
+ // Re-exported so the package's "./attestation" export exposes the full
25
+ // canonicalize -> payloadHashOf -> sign/verify surface from one entry point.
26
+ export { canonicalize }
27
+
28
+ const SCHEMA_PATH = fileURLToPath(new URL('../../contracts/atelier-attestation.v1.schema.json', import.meta.url))
29
+ const SUPPORTED_ALGORITHMS = new Set(['ed25519', 'es256'])
30
+ export const LOCAL_KEY_FILE = 'atelier-attestation-key.local.json'
31
+ export const KEY_ENV_VAR = 'ATELIER_ATTESTATION_KEY_JSON'
32
+
33
+ let cachedValidate = null
34
+
35
+ function attestationValidator() {
36
+ if (!cachedValidate) {
37
+ const ajv = new Ajv2020({ allErrors: true, strict: false })
38
+ addFormats(ajv)
39
+ cachedValidate = ajv.compile(JSON.parse(fs.readFileSync(SCHEMA_PATH, 'utf8')))
40
+ }
41
+ return cachedValidate
42
+ }
43
+
44
+ function schemaErrors(doc) {
45
+ const validate = attestationValidator()
46
+ if (validate(doc)) return []
47
+ return (validate.errors ?? []).map((error) => `${error.instancePath || '/'} ${error.message ?? 'failed schema validation'}`)
48
+ }
49
+
50
+ // Compute the canonical payload hash object for a payload document.
51
+ export function payloadHashOf(document) {
52
+ const digest = crypto.createHash('sha256').update(Buffer.from(canonicalize(document), 'utf8')).digest('hex')
53
+ return { algorithm: 'sha-256', canonicalization: 'RFC8785-JCS', digest }
54
+ }
55
+
56
+ // The bytes a detached signature covers: the UTF-8 encoding of the RFC 8785
57
+ // canonicalization of the document with its `signature` member set to null.
58
+ // This is the shared normative rule — attestations and announcements both
59
+ // sign exactly these bytes.
60
+ export function signingInput(document) {
61
+ if (!document || typeof document !== 'object') {
62
+ throw new TypeError('signingInput requires an attestation object')
63
+ }
64
+ const unsigned = structuredClone(document)
65
+ unsigned.signature = null
66
+ return Buffer.from(canonicalize(unsigned), 'utf8')
67
+ }
68
+
69
+ function importPrivateKey(jwk) {
70
+ return crypto.createPrivateKey({ key: jwk, format: 'jwk' })
71
+ }
72
+
73
+ function importPublicKey(jwk) {
74
+ return crypto.createPublicKey({ key: jwk, format: 'jwk' })
75
+ }
76
+
77
+ function signBytes(algorithm, input, keyObject) {
78
+ if (algorithm === 'ed25519') return crypto.sign(null, input, keyObject)
79
+ if (algorithm === 'es256') return crypto.sign('sha256', input, { key: keyObject, dsaEncoding: 'ieee-p1363' })
80
+ throw new Error(`unsupported attestation signature algorithm: ${algorithm}`)
81
+ }
82
+
83
+ function verifyBytes(algorithm, input, keyObject, signatureBytes) {
84
+ if (algorithm === 'ed25519') return crypto.verify(null, input, keyObject, signatureBytes)
85
+ if (algorithm === 'es256') return crypto.verify('sha256', input, { key: keyObject, dsaEncoding: 'ieee-p1363' }, signatureBytes)
86
+ throw new Error(`unsupported attestation signature algorithm: ${algorithm}`)
87
+ }
88
+
89
+ function jwkMatchesAlgorithm(jwk, algorithm) {
90
+ if (!jwk || typeof jwk !== 'object') return false
91
+ if (algorithm === 'ed25519') return jwk.kty === 'OKP' && jwk.crv === 'Ed25519'
92
+ if (algorithm === 'es256') return jwk.kty === 'EC' && jwk.crv === 'P-256'
93
+ return false
94
+ }
95
+
96
+ // Generic detached-signature layer over any JSON document that carries a
97
+ // `signature` member. No schema knowledge: callers layer their own document
98
+ // validation (the attestation flow validates against its contract; the
99
+ // announcements flow validates structurally in code). Signs the signingInput
100
+ // bytes defined above. Never mutates the input document.
101
+ // Attacker-controlled strings reach terminals through reason messages and
102
+ // listing headers. A keyId carrying ANSI or newlines can erase the warning
103
+ // line above it and print a convincing forgery in its place, so every such
104
+ // value is capped and stripped of anything outside printable ASCII before it
105
+ // is interpolated. Callers never see the raw value.
106
+ export function safeLabel(value, max = 64) {
107
+ if (typeof value !== 'string') return '(none)'
108
+ const printable = Array.from(value, (ch) => (ch >= ' ' && ch <= '~' ? ch : '?')).join('')
109
+ if (printable.length === 0) return '(none)'
110
+ return printable.length > max ? `${printable.slice(0, max)}…` : printable
111
+ }
112
+
113
+ export function signDocument(doc, { privateKey, keyId, algorithm } = {}) {
114
+ if (!doc || typeof doc !== 'object') throw new TypeError('signDocument requires a document object')
115
+ if (!privateKey || typeof privateKey !== 'object') throw new TypeError('signDocument requires a private key JWK')
116
+ if (!keyId || typeof keyId !== 'string') throw new TypeError('signDocument requires a keyId')
117
+ if (!SUPPORTED_ALGORITHMS.has(algorithm)) {
118
+ throw new Error(`unsupported attestation signature algorithm: ${algorithm}`)
119
+ }
120
+ if (!jwkMatchesAlgorithm(privateKey, algorithm)) {
121
+ throw new Error(`signing key ${keyId} privateKeyJwk does not match algorithm ${algorithm}`)
122
+ }
123
+ if (doc.signature !== null) {
124
+ throw new Error('document already carries a signature; refusing to re-sign. Set signature to null first if that is intended.')
125
+ }
126
+ const input = signingInput(doc)
127
+ const signatureBytes = signBytes(algorithm, input, importPrivateKey(privateKey))
128
+ const signed = structuredClone(doc)
129
+ signed.signature = { algorithm, keyId, value: signatureBytes.toString('base64url') }
130
+ return signed
131
+ }
132
+
133
+ // Generic verification for a signDocument-shaped signature. Never throws on
134
+ // bad documents — returns { valid, reasons } with every applicable reason
135
+ // collected. Throws only on programmer error. Reason messages may name keyId
136
+ // and algorithm, never JWK member values or signing-input bytes.
137
+ export function verifyDocument(doc, { publicKey } = {}) {
138
+ if (!doc || typeof doc !== 'object') throw new TypeError('verifyDocument requires a document object')
139
+ if (!publicKey || typeof publicKey !== 'object' || !publicKey.publicKeyJwk) {
140
+ throw new TypeError('verifyDocument requires a public key document with publicKeyJwk')
141
+ }
142
+ const reasons = []
143
+ const signature = doc.signature
144
+ if (!signature || typeof signature !== 'object') {
145
+ reasons.push({ code: 'signature.missing', message: 'signature is null; an unsigned document is non-authoritative and never verifies' })
146
+ return { valid: false, reasons }
147
+ }
148
+ if (signature.algorithm !== publicKey.algorithm || !jwkMatchesAlgorithm(publicKey.publicKeyJwk, signature.algorithm)) {
149
+ reasons.push({
150
+ code: 'signature.algorithm-mismatch',
151
+ message: `signature algorithm ${safeLabel(signature.algorithm, 32)} does not match public key algorithm ${safeLabel(publicKey.algorithm, 32)}`,
152
+ })
153
+ }
154
+ if (signature.keyId !== publicKey.keyId) {
155
+ reasons.push({
156
+ code: 'signature.key-id-mismatch',
157
+ message: `signature keyId ${safeLabel(signature.keyId)} does not match public key keyId ${safeLabel(publicKey.keyId)}`,
158
+ })
159
+ }
160
+ let cryptoValid = false
161
+ try {
162
+ const input = signingInput(doc)
163
+ const signatureBytes = Buffer.from(String(signature.value ?? ''), 'base64url')
164
+ cryptoValid = verifyBytes(signature.algorithm, input, importPublicKey(publicKey.publicKeyJwk), signatureBytes)
165
+ } catch {
166
+ cryptoValid = false
167
+ }
168
+ if (!cryptoValid) {
169
+ reasons.push({ code: 'signature.invalid', message: 'cryptographic verification failed over the canonical signing input' })
170
+ }
171
+ return { valid: reasons.length === 0, reasons }
172
+ }
173
+
174
+ // Generate a fresh signing key pair. Returns the private key document (shape
175
+ // of the local key file) and the freely shareable public key document.
176
+ export function generateKeyPair({ algorithm = 'ed25519', keyId } = {}) {
177
+ if (!keyId || typeof keyId !== 'string') throw new TypeError('generateKeyPair requires a keyId')
178
+ if (!SUPPORTED_ALGORITHMS.has(algorithm)) {
179
+ throw new Error(`unsupported attestation signature algorithm: ${algorithm}`)
180
+ }
181
+ const pair = algorithm === 'ed25519'
182
+ ? crypto.generateKeyPairSync('ed25519')
183
+ : crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' })
184
+ return {
185
+ privateKeyDoc: { keyId, algorithm, privateKeyJwk: pair.privateKey.export({ format: 'jwk' }) },
186
+ publicKeyDoc: { keyId, algorithm, publicKeyJwk: pair.publicKey.export({ format: 'jwk' }) },
187
+ }
188
+ }
189
+
190
+ // Load the signing key with fail-closed precedence:
191
+ // env JSON -> explicit key file path -> ./atelier-attestation-key.local.json.
192
+ export function loadSigningKey({ keyPath = null, env = process.env, cwd = process.cwd() } = {}) {
193
+ let raw = null
194
+ let source = null
195
+ if (env[KEY_ENV_VAR]) {
196
+ raw = env[KEY_ENV_VAR]
197
+ source = `env ${KEY_ENV_VAR}`
198
+ } else if (keyPath) {
199
+ if (!fs.existsSync(keyPath)) throw new Error(`signing key file not found: ${keyPath}`)
200
+ raw = fs.readFileSync(keyPath, 'utf8')
201
+ source = keyPath
202
+ } else {
203
+ const localPath = path.join(cwd, LOCAL_KEY_FILE)
204
+ if (fs.existsSync(localPath)) {
205
+ raw = fs.readFileSync(localPath, 'utf8')
206
+ source = localPath
207
+ }
208
+ }
209
+ if (raw === null) {
210
+ throw new Error(
211
+ 'no attestation signing key available. Provide one of: '
212
+ + `the ${KEY_ENV_VAR} environment variable (key file JSON), `
213
+ + '--key <file>, '
214
+ + `or ${LOCAL_KEY_FILE} in the current directory (gitignored).`,
215
+ )
216
+ }
217
+ let parsed
218
+ try {
219
+ parsed = JSON.parse(raw)
220
+ } catch {
221
+ // Never echo the content: it may be (or contain) private key material.
222
+ throw new Error(`signing key from ${source} is not valid JSON`)
223
+ }
224
+ if (!parsed || typeof parsed !== 'object' || typeof parsed.keyId !== 'string' || !parsed.keyId) {
225
+ throw new Error(`signing key from ${source} is missing a keyId`)
226
+ }
227
+ if (!SUPPORTED_ALGORITHMS.has(parsed.algorithm)) {
228
+ throw new Error(`signing key ${parsed.keyId} has an unsupported algorithm`)
229
+ }
230
+ if (!jwkMatchesAlgorithm(parsed.privateKeyJwk, parsed.algorithm)) {
231
+ throw new Error(`signing key ${parsed.keyId} privateKeyJwk does not match algorithm ${parsed.algorithm}`)
232
+ }
233
+ return { keyId: parsed.keyId, algorithm: parsed.algorithm, privateKeyJwk: parsed.privateKeyJwk }
234
+ }
235
+
236
+ // Sign a schema-valid, unsigned attestation. Returns a new signed document;
237
+ // never mutates the input. Refuses (throws) on schema-invalid input, an
238
+ // already-signed document, or an issuer/key mismatch.
239
+ export function signAttestation({ attestation, key } = {}) {
240
+ if (!attestation || typeof attestation !== 'object') throw new TypeError('signAttestation requires an attestation object')
241
+ if (!key || typeof key !== 'object' || !key.privateKeyJwk) throw new TypeError('signAttestation requires a signing key document')
242
+ if (!SUPPORTED_ALGORITHMS.has(key.algorithm)) {
243
+ throw new Error(`unsupported attestation signature algorithm: ${key.algorithm}`)
244
+ }
245
+ const errors = schemaErrors(attestation)
246
+ if (errors.length) {
247
+ throw new Error(`attestation is not schema-valid; refusing to sign:\n${errors.join('\n')}`)
248
+ }
249
+ if (attestation.issuer?.keyId && attestation.issuer.keyId !== key.keyId) {
250
+ throw new Error(`issuer.keyId ${attestation.issuer.keyId} does not match signing key ${key.keyId}; refusing to sign`)
251
+ }
252
+ return signDocument(attestation, { privateKey: key.privateKeyJwk, keyId: key.keyId, algorithm: key.algorithm })
253
+ }
254
+
255
+ // Verify an attestation signature against a public key document.
256
+ // Never throws on bad documents — returns { valid, reasons } with every
257
+ // applicable reason collected. Throws only on programmer error.
258
+ export function verifyAttestation({ attestation, publicKey } = {}) {
259
+ if (!attestation || typeof attestation !== 'object') throw new TypeError('verifyAttestation requires an attestation object')
260
+ if (!publicKey || typeof publicKey !== 'object' || !publicKey.publicKeyJwk) {
261
+ throw new TypeError('verifyAttestation requires a public key document with publicKeyJwk')
262
+ }
263
+ const reasons = []
264
+ for (const message of schemaErrors(attestation)) {
265
+ reasons.push({ code: 'schema.invalid', message })
266
+ }
267
+ const signature = attestation.signature
268
+ if (signature && typeof signature === 'object' && attestation.issuer?.keyId && attestation.issuer.keyId !== signature.keyId) {
269
+ reasons.push({
270
+ code: 'issuer.key-id-mismatch',
271
+ message: `issuer.keyId ${attestation.issuer.keyId} does not match signature keyId ${signature.keyId}`,
272
+ })
273
+ }
274
+ reasons.push(...verifyDocument(attestation, { publicKey }).reasons)
275
+ return { valid: reasons.length === 0, reasons }
276
+ }
277
+
278
+ // Verify that an attestation is bound to a specific payload document.
279
+ // Independent of signature validity so the two can be reported separately.
280
+ export function verifyPayloadBinding({ attestation, payload } = {}) {
281
+ if (!attestation || typeof attestation !== 'object') throw new TypeError('verifyPayloadBinding requires an attestation object')
282
+ if (payload === undefined) throw new TypeError('verifyPayloadBinding requires a payload document')
283
+ const reasons = []
284
+ const subject = attestation.subject ?? {}
285
+ const payloadSchema = payload && typeof payload === 'object' ? payload.schema : undefined
286
+ if (typeof subject.schema === 'string' && payloadSchema !== subject.schema) {
287
+ reasons.push({
288
+ code: 'payload.schema-field-mismatch',
289
+ message: `payload schema field ${String(payloadSchema)} does not match subject.schema ${subject.schema}`,
290
+ })
291
+ }
292
+ let digest = null
293
+ try {
294
+ digest = payloadHashOf(payload).digest
295
+ } catch (error) {
296
+ reasons.push({ code: 'payload.digest-mismatch', message: `payload cannot be canonicalized: ${error.message}` })
297
+ }
298
+ if (digest !== null && digest !== subject.payloadHash?.digest) {
299
+ reasons.push({
300
+ code: 'payload.digest-mismatch',
301
+ message: 'recomputed payload digest does not match subject.payloadHash.digest',
302
+ })
303
+ }
304
+ return { valid: reasons.length === 0, reasons }
305
+ }
@@ -0,0 +1,262 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { matchesPathPattern, normalizeRelPath } from '../project/path-match.mjs'
3
+
4
+ export const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'
5
+ export const ZERO_SHA_RE = /^0{40,}$/
6
+
7
+ export const RULE_KINDS = Object.freeze(['content', 'path'])
8
+ export const RULE_SEVERITIES = Object.freeze(['error', 'warning'])
9
+
10
+ // A blanket exception is not an exception, it is switching the rule off. The
11
+ // contract requires a rule, a repo, real paths, and a reason a reviewer can act on.
12
+ const BLANKET_PATTERNS = new Set(['*', '**', '.', './', '**/*', '*/**'])
13
+ const MIN_REASON_LENGTH = 8
14
+
15
+ export const DEFAULT_CONTENT_RULES = Object.freeze(
16
+ [
17
+ {
18
+ id: 'secret-material',
19
+ kind: 'content',
20
+ severity: 'error',
21
+ pattern: '(-----BEGIN [A-Z ]*PRIVATE KEY-----|AKIA[0-9A-Z]{16}|ghp_[0-9A-Za-z]{36}|xox[baprs]-[0-9A-Za-z-]{10,})',
22
+ description: 'A credential or private key is being pushed.',
23
+ },
24
+ {
25
+ id: 'browser-persistence',
26
+ kind: 'content',
27
+ severity: 'error',
28
+ pattern: '(^|[^A-Za-z0-9_])(localStorage|sessionStorage|indexedDB|IndexedDB)([^A-Za-z0-9_]|$)|document[.]cookie|caches[.]open',
29
+ paths: ['*.html', '*.js', '*.mjs', '*.cjs', '*.jsx', '*.ts', '*.tsx'],
30
+ description: 'Artifact state must persist to Git-tracked repo files, not browser-only storage.',
31
+ },
32
+ {
33
+ id: 'private-financial-filename',
34
+ kind: 'path',
35
+ severity: 'error',
36
+ pattern: '(\\.env($|\\.)|invoice|payroll|(^|[-_/])salar(y|ies)|bank[-_]?statement|tax[-_]?return|credential|(^|[-_/])password([-_.]|$)|(^|[-_/])secret([-_.]|$)|id_rsa)',
37
+ description: 'Private or financial material does not belong in a source repo.',
38
+ },
39
+ ].map(Object.freeze),
40
+ )
41
+
42
+ const isObject = (value) => value && typeof value === 'object' && !Array.isArray(value)
43
+ const trimmed = (value) => (typeof value === 'string' ? value.trim() : '')
44
+
45
+ export function validateContentRules(rules, { label = 'contentRules' } = {}) {
46
+ const errors = []
47
+ if (!Array.isArray(rules)) return [`${label} must be an array`]
48
+ const seen = new Set()
49
+ for (const [index, rule] of rules.entries()) {
50
+ const at = `${label}[${index}]`
51
+ if (!isObject(rule)) {
52
+ errors.push(`${at} must be an object`)
53
+ continue
54
+ }
55
+ for (const key of Object.keys(rule)) {
56
+ if (!['id', 'kind', 'severity', 'pattern', 'paths', 'description'].includes(key)) errors.push(`${at} must not include additional property ${key}`)
57
+ }
58
+ const id = trimmed(rule.id)
59
+ if (!id) errors.push(`${at}.id is required`)
60
+ else if (seen.has(id)) errors.push(`${at}.id duplicates an earlier rule: ${id}`)
61
+ else seen.add(id)
62
+ if (rule.kind != null && !RULE_KINDS.includes(rule.kind)) errors.push(`${at}.kind must be one of ${RULE_KINDS.join(', ')}`)
63
+ if (rule.severity != null && !RULE_SEVERITIES.includes(rule.severity)) errors.push(`${at}.severity must be one of ${RULE_SEVERITIES.join(', ')}`)
64
+ if (!trimmed(rule.pattern)) errors.push(`${at}.pattern is required`)
65
+ else {
66
+ try {
67
+ new RegExp(rule.pattern)
68
+ } catch (error) {
69
+ errors.push(`${at}.pattern is not a valid regular expression: ${error.message}`)
70
+ }
71
+ }
72
+ if (rule.paths != null && (!Array.isArray(rule.paths) || rule.paths.some((item) => !trimmed(item)))) {
73
+ errors.push(`${at}.paths must be a list of non-empty globs`)
74
+ }
75
+ }
76
+ return errors
77
+ }
78
+
79
+ export function validateContentRuleExceptions(exceptions, rules = DEFAULT_CONTENT_RULES, { label = 'contentRuleExceptions' } = {}) {
80
+ const errors = []
81
+ if (!Array.isArray(exceptions)) return [`${label} must be an array`]
82
+ const ruleIds = new Set(rules.map((rule) => trimmed(rule.id)).filter(Boolean))
83
+ for (const [index, exception] of exceptions.entries()) {
84
+ const at = `${label}[${index}]`
85
+ if (!isObject(exception)) {
86
+ errors.push(`${at} must be an object`)
87
+ continue
88
+ }
89
+ for (const key of Object.keys(exception)) {
90
+ if (!['rule', 'repo', 'paths', 'reason'].includes(key)) errors.push(`${at} must not include additional property ${key}`)
91
+ }
92
+ const rule = trimmed(exception.rule)
93
+ if (!rule) errors.push(`${at}.rule is required; an exception must name the rule it excepts`)
94
+ else if (ruleIds.size && !ruleIds.has(rule)) errors.push(`${at}.rule "${rule}" is not a declared content rule`)
95
+ if (!trimmed(exception.repo)) errors.push(`${at}.repo is required; exceptions are per repo, not fleet-wide`)
96
+ if (!Array.isArray(exception.paths) || !exception.paths.length) {
97
+ errors.push(`${at}.paths is required and must name the paths being excepted`)
98
+ } else {
99
+ for (const item of exception.paths) {
100
+ if (!trimmed(item)) errors.push(`${at}.paths must contain non-empty globs`)
101
+ else if (BLANKET_PATTERNS.has(normalizeRelPath(item))) {
102
+ errors.push(`${at}.paths must not be a blanket "${item}"; a repo-wide skip is disabling the rule, not excepting it`)
103
+ }
104
+ }
105
+ }
106
+ if (trimmed(exception.reason).length < MIN_REASON_LENGTH) {
107
+ errors.push(`${at}.reason is required and must explain why this usage is accepted`)
108
+ }
109
+ }
110
+ return errors
111
+ }
112
+
113
+ export function resolveContentRules(policy) {
114
+ const declared = Array.isArray(policy?.contentRules) ? policy.contentRules : null
115
+ return declared ?? DEFAULT_CONTENT_RULES
116
+ }
117
+
118
+ export function findException({ exceptions = [], rule, repo, path: filePath }) {
119
+ const rel = normalizeRelPath(filePath)
120
+ return (
121
+ exceptions.find(
122
+ (exception) =>
123
+ trimmed(exception.rule) === rule &&
124
+ trimmed(exception.repo) === repo &&
125
+ (Array.isArray(exception.paths) ? exception.paths : []).some((pattern) => matchesPathPattern(pattern, rel)),
126
+ ) ?? null
127
+ )
128
+ }
129
+
130
+ function rulePathMatches(rule, filePath) {
131
+ if (!Array.isArray(rule.paths) || !rule.paths.length) return true
132
+ return rule.paths.some((pattern) => matchesPathPattern(pattern, filePath))
133
+ }
134
+
135
+ /**
136
+ * Judge added lines and added file paths, not the whole tree.
137
+ *
138
+ * A whole-tree scan cannot tell "you are about to push a new violation" from
139
+ * "a known, accepted usage exists", so one legitimate use anywhere blocks every
140
+ * push of everything in that repo, forever.
141
+ */
142
+ export function scanAddedContent({ files, rules = DEFAULT_CONTENT_RULES, exceptions = [], repo = null }) {
143
+ const findings = []
144
+ for (const file of files) {
145
+ const filePath = normalizeRelPath(file.path)
146
+ for (const rule of rules) {
147
+ const kind = rule.kind ?? 'content'
148
+ const severity = rule.severity ?? 'error'
149
+ const exception = findException({ exceptions, rule: rule.id, repo, path: filePath })
150
+ if (kind === 'path') {
151
+ if (!file.added || !new RegExp(rule.pattern, 'i').test(filePath)) continue
152
+ if (exception) continue
153
+ findings.push({ severity, code: 'content-rule-violation', rule: rule.id, repo, path: filePath, line: null, message: `${repo ?? ''}/${filePath}: ${rule.description ?? rule.id}`.replace(/^\//, '') })
154
+ continue
155
+ }
156
+ if (!rulePathMatches(rule, filePath)) continue
157
+ const re = new RegExp(rule.pattern)
158
+ for (const line of file.addedLines ?? []) {
159
+ if (!re.test(line.text)) continue
160
+ if (exception) continue
161
+ findings.push({
162
+ severity,
163
+ code: 'content-rule-violation',
164
+ rule: rule.id,
165
+ repo,
166
+ path: filePath,
167
+ line: line.number,
168
+ message: `${repo ?? ''}/${filePath}:${line.number}: ${rule.description ?? rule.id}`.replace(/^\//, ''),
169
+ })
170
+ }
171
+ }
172
+ }
173
+ return findings
174
+ }
175
+
176
+ /** Parse `git diff` output into added files and added lines with line numbers. */
177
+ export function parseAddedContent(diff) {
178
+ const files = []
179
+ let current = null
180
+ let lineNumber = 0
181
+ for (const raw of String(diff ?? '').split('\n')) {
182
+ if (raw.startsWith('diff --git ')) {
183
+ const header = raw.match(/^diff --git a\/(.*) b\/(.*)$/)
184
+ current = { path: header ? normalizeRelPath(header[2]) : null, added: false, addedLines: [] }
185
+ files.push(current)
186
+ continue
187
+ }
188
+ if (!current) continue
189
+ if (raw.startsWith('new file mode ')) {
190
+ current.added = true
191
+ continue
192
+ }
193
+ const hunk = raw.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/)
194
+ if (hunk) {
195
+ lineNumber = Number(hunk[1])
196
+ continue
197
+ }
198
+ if (/^\+(?!\+)/.test(raw)) {
199
+ current.addedLines.push({ number: lineNumber, text: raw.slice(1) })
200
+ lineNumber += 1
201
+ } else if (/^-(?!-)/.test(raw)) {
202
+ // removed lines do not advance the new-file line counter
203
+ } else if (raw.startsWith(' ')) {
204
+ lineNumber += 1
205
+ }
206
+ }
207
+ return files.filter((file) => file.path)
208
+ }
209
+
210
+ /** Ref updates arrive on the pre-push hook's stdin as `<localRef> <localSha> <remoteRef> <remoteSha>`. */
211
+ export function parsePushRefUpdates(text) {
212
+ const updates = []
213
+ for (const raw of String(text ?? '').split('\n')) {
214
+ const parts = raw.trim().split(/\s+/).filter(Boolean)
215
+ if (parts.length < 4) continue
216
+ const [localRef, localSha, remoteRef, remoteSha] = parts
217
+ if (ZERO_SHA_RE.test(localSha)) continue // branch deletion pushes no content
218
+ updates.push({ localRef, localSha, remoteRef, remoteSha })
219
+ }
220
+ return updates
221
+ }
222
+
223
+ function gitOutput(repoRoot, args) {
224
+ const result = spawnSync('git', ['-C', repoRoot, ...args], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 })
225
+ return result.status === 0 ? result.stdout : null
226
+ }
227
+
228
+ export function pushRangeBase(repoRoot, remoteSha) {
229
+ // A new branch has no remote counterpart, so everything it carries is new:
230
+ // diff against the empty tree rather than silently scanning nothing.
231
+ if (!remoteSha || ZERO_SHA_RE.test(remoteSha)) return EMPTY_TREE
232
+ return gitOutput(repoRoot, ['rev-parse', '--verify', '--quiet', `${remoteSha}^{commit}`]) ? remoteSha : EMPTY_TREE
233
+ }
234
+
235
+ export function diffForPushRange(repoRoot, { localSha, remoteSha }) {
236
+ const base = pushRangeBase(repoRoot, remoteSha)
237
+ return gitOutput(repoRoot, ['diff', '--unified=0', base, localSha]) ?? ''
238
+ }
239
+
240
+ export function stagedDiff(repoRoot) {
241
+ return gitOutput(repoRoot, ['diff', '--cached', '--unified=0']) ?? ''
242
+ }
243
+
244
+ /**
245
+ * Whole-tree scan for the non-blocking audit. This is the view that reports every
246
+ * accepted usage, so a repo can see what it has taken on without those findings
247
+ * stopping unrelated work from being pushed.
248
+ */
249
+ export function scanTree(repoRoot, { rules = DEFAULT_CONTENT_RULES, exceptions = [], repo = null } = {}) {
250
+ const listed = gitOutput(repoRoot, ['ls-files', '-z']) ?? ''
251
+ const files = []
252
+ for (const rel of listed.split('\0').filter(Boolean)) {
253
+ const blob = gitOutput(repoRoot, ['show', `HEAD:${rel}`])
254
+ if (blob == null) continue
255
+ files.push({
256
+ path: rel,
257
+ added: true,
258
+ addedLines: blob.split('\n').map((text, index) => ({ number: index + 1, text })),
259
+ })
260
+ }
261
+ return scanAddedContent({ files, rules, exceptions, repo })
262
+ }