@kontourai/corpus 0.0.0-bootstrap.0 → 0.8.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.
@@ -0,0 +1,85 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://raw.githubusercontent.com/kontourai/corpus/main/schemas/decision-record.schema.json",
4
+ "title": "Corpus decision record",
5
+ "description": "YAML frontmatter of a topic-keyed living decision record at <decisions.dir>/<topic-slug>.md, enforced by `corpus check` and `corpus decisions check` when corpus.json declares `decisions`. One file per decision subject; supersession is an edit to the file, and a tombstone (superseded_by or merged_into) names the slug that now carries the answer. Frontmatter is read as YAML with every scalar a string. Rules a JSON Schema cannot state are enforced by Corpus too: the filename is a kebab-case slug, relation targets name existing records other than this one, and the generated index is fresh. Numbered ADRs are frozen history and are never validated by this schema. See https://github.com/kontourai/corpus/blob/main/docs/decisions.md.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["status", "subject", "decided", "evidence"],
9
+ "properties": {
10
+ "status": {
11
+ "type": "string",
12
+ "enum": ["current", "superseded", "merged", "needs-decision"],
13
+ "description": "current: this file holds the live answer for its subject. superseded: the subject moved to another topic slug (see superseded_by); this file is a tombstone. merged: the subject was folded into another topic slug (see merged_into); this file is a tombstone. needs-decision: a stub that names an open subject with no ratified answer yet; it carries provenance evidence but no live decision."
14
+ },
15
+ "subject": {
16
+ "type": "string",
17
+ "pattern": "^(?=.*\\S)[^\\u0000-\\u001F\\u007F]*$",
18
+ "description": "The decision subject: a noun phrase, ideally from the repository's domain vocabulary (not enforced). A single line with a non-whitespace character and no control characters, because it is a cell of the generated index table."
19
+ },
20
+ "decided": {
21
+ "type": "string",
22
+ "pattern": "^\\d{4}-\\d{2}-\\d{2}$",
23
+ "description": "ISO date (YYYY-MM-DD) the current decision was ratified; for needs-decision stubs, the date the stub was recorded."
24
+ },
25
+ "evidence": {
26
+ "type": "array",
27
+ "minItems": 1,
28
+ "description": "Provenance is linked here, never inlined. Secret-shaped literals are refused.",
29
+ "items": {
30
+ "type": "object",
31
+ "additionalProperties": false,
32
+ "required": ["kind", "ref"],
33
+ "properties": {
34
+ "kind": {
35
+ "type": "string",
36
+ "enum": ["issue", "pr", "commit", "session-archive", "adr", "doc", "url"],
37
+ "description": "issue/pr/commit: a GitHub reference (number, URL, or SHA). session-archive: a path to an archived session artifact. adr: a frozen numbered ADR. doc: a repository document path. url: an external permalink."
38
+ },
39
+ "ref": {
40
+ "type": "string",
41
+ "pattern": "\\S",
42
+ "description": "The reference literal. Must not contain secret-shaped material (access keys, tokens, private keys, JWTs, or a quoted secret/password/token/api key assignment).",
43
+ "not": {
44
+ "pattern": "(AKIA[A-Z0-9]{16}|ASIA[A-Z0-9]{16}|gh[pousr]_[A-Za-z0-9_]{36,}|BEGIN[A-Z ]*PRIVATE KEY|eyJ[A-Za-z0-9_-]{10,}\\.eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}|([Ss][Ee][Cc][Rr][Ee][Tt]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Tt][Oo][Kk][Ee][Nn]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy])\\s*[:=]\\s*[\"'][^\"']{8,})"
45
+ }
46
+ }
47
+ }
48
+ }
49
+ },
50
+ "supersedes": {
51
+ "type": "array",
52
+ "description": "Optional. Topic slugs whose subjects this record absorbed. Each must name an existing record.",
53
+ "items": { "$ref": "#/$defs/slug" }
54
+ },
55
+ "superseded_by": {
56
+ "allOf": [{ "$ref": "#/$defs/slug" }],
57
+ "description": "Present only when status is superseded: the existing topic slug, other than this record's own, that now carries the answer."
58
+ },
59
+ "merged_into": {
60
+ "allOf": [{ "$ref": "#/$defs/slug" }],
61
+ "description": "Present only when status is merged: the existing topic slug, other than this record's own, this subject was folded into."
62
+ }
63
+ },
64
+ "allOf": [
65
+ {
66
+ "if": { "properties": { "status": { "enum": ["current", "needs-decision"] } }, "required": ["status"] },
67
+ "then": { "not": { "anyOf": [{ "required": ["superseded_by"] }, { "required": ["merged_into"] }] } }
68
+ },
69
+ {
70
+ "if": { "properties": { "status": { "const": "superseded" } }, "required": ["status"] },
71
+ "then": { "required": ["superseded_by"], "not": { "required": ["merged_into"] } }
72
+ },
73
+ {
74
+ "if": { "properties": { "status": { "const": "merged" } }, "required": ["status"] },
75
+ "then": { "required": ["merged_into"], "not": { "required": ["superseded_by"] } }
76
+ }
77
+ ],
78
+ "$defs": {
79
+ "slug": {
80
+ "type": "string",
81
+ "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
82
+ "description": "A topic slug: lowercase kebab-case. Resolves to <decisions.dir>/<slug>.md."
83
+ }
84
+ }
85
+ }
package/src/assets.mjs ADDED
@@ -0,0 +1,67 @@
1
+ import { lstatSync, statSync } from 'node:fs';
2
+ import { trackedFiles } from './git.mjs';
3
+ import { toFsPath } from './paths.mjs';
4
+
5
+ export const MAX_ASSET_BYTES = 25 * 1024 * 1024;
6
+
7
+ /** The first path component (repo-relative) that is a symlink, or null. */
8
+ function symlinkComponent(root, repoPath) {
9
+ const parts = repoPath.split('/').filter(Boolean);
10
+ for (let i = 1; i <= parts.length; i++) {
11
+ const prefix = parts.slice(0, i).join('/');
12
+ try {
13
+ if (lstatSync(toFsPath(root, prefix)).isSymbolicLink()) return prefix;
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+ return null;
19
+ }
20
+
21
+ /**
22
+ * Outside git there is no tracked set, so only these media/document types are
23
+ * copied. Defense in depth: `build` needs a git sha today, so the CLI cannot
24
+ * reach this branch.
25
+ */
26
+ const ALLOWED_OUTSIDE_GIT = /\.(?:png|jpe?g|gif|svg|webp|avif|ico|bmp|mp4|webm|pdf|txt|json|css|woff2?|ttf|otf)$/i;
27
+
28
+ /**
29
+ * Decide what a local reference may become on the published site. One policy
30
+ * for `check` and `build`, so a reference check accepts is one build can
31
+ * publish:
32
+ * - unpublishable: under .git/, through a symlink, or (inside git) a file that is not tracked,
33
+ * i.e. untracked or git-ignored. These would leak local secrets such as
34
+ * `.env` or `.git/config`, so they fail with exit 1.
35
+ * - copy: inside git a tracked file, outside git an allowlisted extension;
36
+ * either way at most MAX_ASSET_BYTES. Only for embedded references.
37
+ * - link: everything else keeps the GitHub blob/tree link.
38
+ */
39
+ export function createAssetPolicy(root) {
40
+ let tracked;
41
+ const trackedSet = () => (tracked ??= (() => {
42
+ const files = trackedFiles(root);
43
+ return files === null ? null : new Set(files);
44
+ })());
45
+
46
+ return (repoPath, kind, { embed = false } = {}) => {
47
+ if (repoPath.split('/').includes('.git')) return { action: 'unpublishable', reason: 'is inside .git' };
48
+ // Simplest safe rule: a symlink anywhere in the path is refused, whatever
49
+ // it points at. A tracked `pic.png -> .env` would otherwise pass the
50
+ // tracked check by name while copyFileSync published the target.
51
+ const link = symlinkComponent(root, repoPath);
52
+ if (link) return { action: 'unpublishable', reason: `goes through the symlink ${link}; symlinked assets are not published` };
53
+ if (kind !== 'file') return { action: 'link' };
54
+ const set = trackedSet();
55
+ if (set !== null && !set.has(repoPath)) return { action: 'unpublishable', reason: 'is not tracked by git (untracked or ignored)' };
56
+ if (!embed) return { action: 'link' };
57
+ if (set === null && !ALLOWED_OUTSIDE_GIT.test(repoPath)) return { action: 'link' };
58
+ let size;
59
+ try {
60
+ size = statSync(toFsPath(root, repoPath)).size;
61
+ } catch {
62
+ return { action: 'link' };
63
+ }
64
+ if (size > MAX_ASSET_BYTES) return { action: 'link', reason: `is larger than ${MAX_ASSET_BYTES / 1024 / 1024} MB` };
65
+ return { action: 'copy' };
66
+ };
67
+ }