@supersuit/hypercontext 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/find.mjs ADDED
@@ -0,0 +1,157 @@
1
+ // Where each kind's records live in a workspace, as paths relative to the workspace root.
2
+ //
3
+ // The layout is the one Supersuit writes (self/, people/, superprojects/, documents/ ...), plus
4
+ // the names it used before (projects/, PROJECT.md, PLAN.md), because a workspace mid-conversion
5
+ // holds both and a check that saw only one would report the other as clean. Nothing here reads a
6
+ // record; it lists files. The filesystem is injected so tests never read the machine.
7
+ import { readdirSync, existsSync, lstatSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+
10
+ export const fsDefault = {
11
+ list: (p) => { try { return readdirSync(p); } catch { return []; } },
12
+ // lstat, never stat: a linked file or folder is another tree's record (a document symlinked
13
+ // in from a company workspace, a superproject shared by link). Reading it here would count it
14
+ // twice, and stamping it would write into a repo this check was never pointed at.
15
+ isDir: (p) => { try { return lstatSync(p).isDirectory(); } catch { return false; } },
16
+ isFile: (p) => { try { return lstatSync(p).isFile(); } catch { return false; } },
17
+ exists: (p) => existsSync(p),
18
+ };
19
+
20
+ // A folder name that is never part of the record tree: hidden, private scratch, dependencies.
21
+ const skip = (name) => name.startsWith('.') || name.startsWith('_') || name === 'node_modules';
22
+ const NOT_RECORDS = new Set(['README.md', 'CLAUDE.md', 'AGENTS.md', '_TEMPLATE.md', 'TEMPLATE.md']);
23
+
24
+ /**
25
+ * The superproject roots: superprojects/, and projects/ unless it is the link the rename leaves
26
+ * behind (isDir never follows a link, so that link is not a second root).
27
+ */
28
+ export function superprojectRoots(ws, fs = fsDefault) {
29
+ return ['superprojects', 'projects'].filter((r) => fs.isDir(join(ws, r)));
30
+ }
31
+
32
+ // Every folder under a superprojects root that is a superproject (holds a record file), at any
33
+ // depth: done/, a parent's own superprojects/ or projects/, never inside a documents/ folder.
34
+ const PROJECT_FILES = ['SUPERPROJECT.md', 'PROJECT.md'];
35
+ export function superprojectDirs(ws, fs = fsDefault) {
36
+ const out = [];
37
+ const walk = (rel, depth) => {
38
+ if (depth > 6) return;
39
+ for (const name of fs.list(join(ws, rel)).sort()) {
40
+ if (skip(name) || name === 'documents') continue;
41
+ const child = `${rel}/${name}`;
42
+ if (!fs.isDir(join(ws, child))) continue;
43
+ const files = fs.list(join(ws, child));
44
+ if (PROJECT_FILES.some((f) => files.includes(f)) || files.includes('STATE.md')) out.push(child);
45
+ walk(child, depth + 1);
46
+ }
47
+ };
48
+ for (const root of superprojectRoots(ws, fs)) walk(root, 0);
49
+ return out;
50
+ }
51
+
52
+ // The documents/ folders a workspace keeps: the root one and every superproject's own.
53
+ export function documentHomes(ws, fs = fsDefault) {
54
+ const homes = fs.isDir(join(ws, 'documents')) ? ['documents'] : [];
55
+ for (const d of superprojectDirs(ws, fs)) if (fs.isDir(join(ws, d, 'documents'))) homes.push(`${d}/documents`);
56
+ return homes;
57
+ }
58
+
59
+ const filesIn = (ws, rel, fs, pick) =>
60
+ fs.list(join(ws, rel)).filter((n) => !skip(n) && fs.isFile(join(ws, rel, n)) && pick(n)).sort().map((n) => `${rel}/${n}`);
61
+
62
+ const dirsIn = (ws, rel, fs) =>
63
+ fs.list(join(ws, rel)).filter((n) => !skip(n) && fs.isDir(join(ws, rel, n))).sort();
64
+
65
+ // Every piece.md at any depth under a documents home.
66
+ function pieces(ws, home, fs) {
67
+ const out = [];
68
+ const walk = (rel, depth) => {
69
+ if (depth > 6) return;
70
+ for (const name of fs.list(join(ws, rel)).sort()) {
71
+ if (skip(name)) continue;
72
+ const child = `${rel}/${name}`;
73
+ if (name === 'piece.md' && fs.isFile(join(ws, child))) out.push(child);
74
+ else if (fs.isDir(join(ws, child))) walk(child, depth + 1);
75
+ }
76
+ };
77
+ walk(home, 0);
78
+ return out;
79
+ }
80
+
81
+ /** Every record of one kind, as workspace-relative paths, sorted. */
82
+ export function findRecords(ws, kind, fs = fsDefault) {
83
+ const md = (n) => n.endsWith('.md') && !NOT_RECORDS.has(n);
84
+ switch (kind) {
85
+ case 'self':
86
+ return fs.isFile(join(ws, 'self/self.md')) ? ['self/self.md'] : [];
87
+ case 'friend':
88
+ // An archived person keeps their record under people/archive/, and it is still a record.
89
+ return [
90
+ ...dirsIn(ws, 'people', fs).filter((n) => n !== 'groups' && fs.isFile(join(ws, 'people', n, 'friend.md'))).map((n) => `people/${n}/friend.md`),
91
+ ...dirsIn(ws, 'people/archive', fs).filter((n) => fs.isFile(join(ws, 'people/archive', n, 'friend.md'))).map((n) => `people/archive/${n}/friend.md`),
92
+ ];
93
+ case 'group':
94
+ return dirsIn(ws, 'people/groups', fs).filter((n) => fs.isFile(join(ws, 'people/groups', n, 'group.md')))
95
+ .map((n) => `people/groups/${n}/group.md`);
96
+ case 'superproject':
97
+ return superprojectDirs(ws, fs).flatMap((d) => {
98
+ const f = PROJECT_FILES.find((x) => fs.isFile(join(ws, d, x)));
99
+ return f ? [`${d}/${f}`] : [];
100
+ });
101
+ case 'state':
102
+ return superprojectDirs(ws, fs).filter((d) => fs.isFile(join(ws, d, 'STATE.md'))).map((d) => `${d}/STATE.md`);
103
+ case 'atomic-action-map':
104
+ return superprojectDirs(ws, fs).flatMap((d) => {
105
+ const f = ['ATOMIC-ACTION-MAP.md', 'PLAN.md'].find((x) => fs.isFile(join(ws, d, x)));
106
+ return f ? [`${d}/${f}`] : [];
107
+ });
108
+ case 'superobjective': {
109
+ const out = [];
110
+ for (const home of ['self/calling/superobjectives', 'superobjectives']) {
111
+ const walk = (rel, depth) => {
112
+ if (depth > 3) return;
113
+ for (const n of dirsIn(ws, rel, fs)) {
114
+ if (fs.isFile(join(ws, rel, n, 'superobjective.md'))) out.push(`${rel}/${n}/superobjective.md`);
115
+ else walk(`${rel}/${n}`, depth + 1);
116
+ }
117
+ };
118
+ walk(home, 0);
119
+ }
120
+ return out;
121
+ }
122
+ case 'piece':
123
+ return documentHomes(ws, fs).flatMap((h) => pieces(ws, h, fs));
124
+ case 'conversation':
125
+ return dirsIn(ws, 'meeting-transcripts', fs).filter((n) => fs.isFile(join(ws, 'meeting-transcripts', n, 'transcript.md')))
126
+ .map((n) => `meeting-transcripts/${n}/transcript.md`);
127
+ case 'document':
128
+ return documentHomes(ws, fs).flatMap((h) => filesIn(ws, h, fs, md));
129
+ case 'checkpoint':
130
+ return filesIn(ws, 'checkpoints', fs, md);
131
+ case 'hdsop':
132
+ return filesIn(ws, 'workflows', fs, (n) => md(n) && n !== 'CATALOG.md');
133
+ case 'workflow-catalog':
134
+ return fs.isFile(join(ws, 'workflows/CATALOG.md')) ? ['workflows/CATALOG.md'] : [];
135
+ case 'recurrent':
136
+ return filesIn(ws, 'recurrents', fs, md);
137
+ case 'tile-app': {
138
+ const out = [];
139
+ for (const home of ['helm/tile-apps', 'tile-apps']) {
140
+ for (const n of dirsIn(ws, home, fs)) if (fs.isFile(join(ws, home, n, 'app.json'))) out.push(`${home}/${n}/app.json`);
141
+ }
142
+ return out;
143
+ }
144
+ default:
145
+ throw new Error(`no such kind: ${kind}`);
146
+ }
147
+ }
148
+
149
+ /** Records a workspace still holds in a shape a kind's standard retired, which no finder above sees. */
150
+ export function legacyRecords(ws, fs = fsDefault) {
151
+ const out = [];
152
+ if (fs.isFile(join(ws, 'self/SUPEROBJECTIVE.md'))) out.push({ kind: 'superobjective', path: 'self/SUPEROBJECTIVE.md' });
153
+ for (const n of fs.list(join(ws, 'people'))) {
154
+ if (n.endsWith('.md') && !NOT_RECORDS.has(n) && fs.isFile(join(ws, 'people', n))) out.push({ kind: 'friend', path: `people/${n}` });
155
+ }
156
+ return out;
157
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,9 @@
1
+ export { KINDS, STANDARD_BASE, CURRENT_VERSION, byKind, bareStandard, versionOf } from './kinds.mjs';
2
+ export { manifestHome, implementedVersions, readManifest, exportManifests, sameManifest, checkOnline, manifestFindings, MANIFEST_CODE } from './manifest.mjs';
3
+ export { findRecords, legacyRecords, superprojectRoots, superprojectDirs, documentHomes } from './find.mjs';
4
+ export { judge, standardOf, stampStandard, CODE, VERSION_CODE } from './selfdoc.mjs';
5
+ export { check } from './check.mjs';
6
+ export { stamp, PACKAGE_STAMPS } from './stamp.mjs';
7
+ export { readBaseline, applyBaseline, writeBaseline, keyOf } from './baseline.mjs';
8
+ export { renderReport, renderStamp } from './render.mjs';
9
+ export { resolvePackage, normalize } from './packages.mjs';
package/src/kinds.mjs ADDED
@@ -0,0 +1,71 @@
1
+ // Every kind of record a hypercontext workspace holds: where its records live, the one page that
2
+ // is its standard, and the package that checks its shape.
3
+ //
4
+ // THIS FILE DEFINES NO RECORD TYPE. A kind's shape belongs to its own package (friend-md,
5
+ // group-md, superprojects ...), and this list only says where to look and whom to ask. The one
6
+ // rule this package owns is the property every record shares, hypercontext itself: a record
7
+ // names the standard it follows, so a stranger or a fresh agent can open it cold and find its
8
+ // rules (Gary, 2026-10-17: "AMNESIAC AIS need to be able to refer the standard a file/folder is
9
+ // supposed to uphold"; 2026-10-20: "every instance ... needs to be self documenting").
10
+ //
11
+ // A kind with `pkg: null` has no package yet. It is still checked for the shared property, and
12
+ // the report says in one line that nothing checks its shape, so the gap is visible from the
13
+ // same command that would close it.
14
+
15
+ // The URL shapes and how a URL reads back as a version are shared by every record package, so they
16
+ // live in @supersuit/hypercontext-core and are re-exported here unchanged.
17
+ import { STANDARD_BASE, bareStandard, standardUrl, versionOf } from '@supersuit/hypercontext-core';
18
+ export { STANDARD_BASE, bareStandard, versionOf };
19
+ // A record names one VERSION of its standard (docs/STANDARDS.md): the page at /v<N> never changes
20
+ // meaning, so the link says which rules the record was written to. The bare /standards/<kind>
21
+ // is the latest page, for people; a record carrying it predates versions.
22
+ export const CURRENT_VERSION = 1;
23
+ const url = (slug, v = CURRENT_VERSION) => standardUrl(slug, v);
24
+
25
+ /**
26
+ * @typedef {object} Kind
27
+ * @property {string} kind the record kind, as the report names it
28
+ * @property {string} label what a person calls it
29
+ * @property {string} standard the versioned page its `standard:` field must name
30
+ * @property {'md'|'json'} format where the field lives: a YAML header or a JSON key
31
+ * @property {string[]} context the shared context keys this kind's standard requires
32
+ * @property {object|null} pkg the package that checks its shape, or null when none exists
33
+ * @property {string} [sharesLint] another kind whose package run already covers this one
34
+ * @property {boolean} [stampable] false when no tool may add the field yet (see `why`)
35
+ * @property {string} [why] why a kind is not stampable, said in the report
36
+ */
37
+
38
+ const CONTEXT = Object.freeze(['id', 'summary', 'read_when', 'as_of', 'keep']);
39
+ const SUPERPROJECTS = Object.freeze({ name: '@supersuit/superprojects', repo: 'superprojects', bin: 'bin/superprojects.mjs' });
40
+
41
+ /** @type {readonly Kind[]} */
42
+ export const KINDS = Object.freeze([
43
+ { kind: 'self', label: 'the owner, self/self.md', standard: url('self'), format: 'md', context: CONTEXT,
44
+ pkg: { name: '@supersuit/self-md', repo: 'self-md', bin: 'bin/self-md.mjs' } },
45
+ { kind: 'friend', label: 'people, people/<slug>/friend.md', standard: url('friend'), format: 'md', context: CONTEXT,
46
+ pkg: { name: '@supersuit/friend-md', repo: 'friend-md', bin: 'bin/friend-md.mjs' } },
47
+ { kind: 'group', label: 'groups, people/groups/<id>/group.md', standard: url('group'), format: 'md', context: [],
48
+ pkg: { name: '@supersuit/group-md', repo: 'group-md', bin: 'bin/group-md.mjs' } },
49
+ { kind: 'superproject', label: 'superprojects, SUPERPROJECT.md', standard: url('superproject'), format: 'md', context: [],
50
+ pkg: SUPERPROJECTS },
51
+ { kind: 'state', label: 'superproject state, STATE.md', standard: url('state'), format: 'md', context: [],
52
+ pkg: SUPERPROJECTS, sharesLint: 'superproject' },
53
+ { kind: 'atomic-action-map', label: 'atomic action maps, ATOMIC-ACTION-MAP.md', standard: url('atomic-action-map'), format: 'md', context: [],
54
+ pkg: SUPERPROJECTS, sharesLint: 'superproject' },
55
+ { kind: 'superobjective', label: 'superobjectives, superobjective.md', standard: url('superobjective'), format: 'md', context: CONTEXT,
56
+ pkg: { name: '@supersuit/superobjective', repo: 'superobjective', bin: 'bin/superobjective.mjs' } },
57
+ { kind: 'piece', label: 'pieces, piece.md', standard: url('piece'), format: 'md', context: CONTEXT,
58
+ pkg: { name: '@supersuit/piece-md', repo: 'piece-md', bin: 'bin/piece-md.mjs' } },
59
+ { kind: 'conversation', label: 'conversations, transcript.md', standard: url('conversation'), format: 'md', context: [],
60
+ pkg: { name: '@supersuit/transcript-md', repo: 'transcript-md', bin: 'lib/freedom-conversations-cli.mjs' } },
61
+ { kind: 'document', label: 'documents, documents/*.md', standard: url('document'), format: 'md', context: [], pkg: null },
62
+ { kind: 'checkpoint', label: 'checkpoints, checkpoints/*.md', standard: url('checkpoint'), format: 'md', context: [], pkg: null },
63
+ { kind: 'hdsop', label: 'workflow maps, workflows/*.md', standard: url('hdsop'), format: 'md', context: [], pkg: null },
64
+ { kind: 'workflow-catalog', label: 'the workflow catalog, workflows/CATALOG.md', standard: url('workflow-catalog'), format: 'md', context: [], pkg: null },
65
+ { kind: 'recurrent', label: 'recurrents, recurrents/*.md', standard: url('recurrent'), format: 'md', context: [], pkg: null,
66
+ stampable: false, why: 'a recurrent keeps its fields as bold labels in the body; a header added beside them would hold a second last run that drifts, so its own writer converts it' },
67
+ { kind: 'tile-app', label: 'tile apps, <app>/app.json', standard: url('tile-app'), format: 'json', context: [], pkg: null,
68
+ stampable: false, why: 'the Helm host reads app.json, and a key it does not know waits until the host accepts one' },
69
+ ]);
70
+
71
+ export const byKind = (k) => KINDS.find((x) => x.kind === k);
@@ -0,0 +1,127 @@
1
+ // The manifests: each version of each standard as data (docs/STANDARDS.md).
2
+ //
3
+ // ONE SOURCE. A kind's manifest is the file its own package ships at standards/<kind>/v<N>.json,
4
+ // and this package ships them only for kinds no package owns yet. Nothing here writes a
5
+ // manifest's content; `exportManifests` copies each from its owner so the wiki publishes the
6
+ // owner's bytes, and `--online` proves the published copy is still the owner's.
7
+ import { readFileSync, readdirSync, mkdirSync, writeFileSync, existsSync } from 'node:fs';
8
+ import { join, dirname } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { KINDS, STANDARD_BASE, CURRENT_VERSION } from './kinds.mjs';
11
+ import { resolvePackage } from './packages.mjs';
12
+ import { validateManifest } from '@supersuit/hypercontext-core';
13
+
14
+ /** The finding for a manifest an owner ships that a reader with no package could not use. */
15
+ export const MANIFEST_CODE = 'manifest:invalid';
16
+
17
+ const OWN = join(dirname(dirname(fileURLToPath(import.meta.url))), 'standards');
18
+
19
+ /** The folder holding a kind's manifests, and who owns it. */
20
+ export function manifestHome(kind, { packagesDir } = {}) {
21
+ if (kind.pkg) {
22
+ const dir = resolvePackage(kind.pkg, { packagesDir });
23
+ if (dir && existsSync(join(dir, 'standards', kind.kind))) return { dir: join(dir, 'standards', kind.kind), owner: kind.pkg.name };
24
+ return { dir: null, owner: kind.pkg.name };
25
+ }
26
+ return { dir: join(OWN, kind.kind), owner: '@supersuit/hypercontext' };
27
+ }
28
+
29
+ /** Every version a kind's owner ships a manifest for; [current] when none can be read. */
30
+ export function implementedVersions(kind, opts = {}) {
31
+ const { dir } = manifestHome(kind, opts);
32
+ if (!dir) return [CURRENT_VERSION];
33
+ const got = readdirSync(dir).map((n) => n.match(/^v(\d+)\.json$/)).filter(Boolean).map((m) => Number(m[1])).sort((a, b) => a - b);
34
+ return got.length ? got : [CURRENT_VERSION];
35
+ }
36
+
37
+ /** A kind's manifest for one version, from its owner, or null. */
38
+ export function readManifest(kind, version = CURRENT_VERSION, opts = {}) {
39
+ const { dir } = manifestHome(kind, opts);
40
+ if (!dir) return null;
41
+ try { return JSON.parse(readFileSync(join(dir, `v${version}.json`), 'utf8')); } catch { return null; }
42
+ }
43
+
44
+ /**
45
+ * Every manifest a kind's owner ships, judged by @supersuit/hypercontext-core's validateManifest
46
+ * (STANDARDS.md section 2), plus the two things only the file's place can say: it is the manifest
47
+ * of this kind, and of the version its file name says. One error finding per bad file.
48
+ * @returns {Array<{kind, level, code, path, message}>}
49
+ */
50
+ export function manifestFindings(kind, opts = {}) {
51
+ const { dir, owner } = manifestHome(kind, opts);
52
+ if (!dir) return [];
53
+ const out = [];
54
+ for (const name of readdirSync(dir).filter((n) => /^v\d+\.json$/.test(n)).sort()) {
55
+ const rel = `standards/${kind.kind}/${name}`;
56
+ const bad = (why) => out.push({ kind: kind.kind, level: 'error', code: MANIFEST_CODE, path: '', message: `${owner} ships ${rel}, ${why}` });
57
+ let m;
58
+ try { m = JSON.parse(readFileSync(join(dir, name), 'utf8')); } catch { bad('which is not JSON'); continue; }
59
+ const { problems } = validateManifest(m);
60
+ if (m && typeof m === 'object') {
61
+ if (m.kind !== kind.kind) problems.push(`its kind is ${JSON.stringify(m.kind)}, not ${kind.kind}`);
62
+ if (m.version !== Number(name.match(/\d+/)[0])) problems.push(`its version is ${JSON.stringify(m.version)}, not the ${name.replace('.json', '')} its file name says`);
63
+ }
64
+ if (problems.length) bad(`which is not a valid manifest: ${problems.join('; ')}`);
65
+ }
66
+ return out;
67
+ }
68
+
69
+ /**
70
+ * Copy every kind's manifests from their owners into `out/<kind>/v<N>.json`, plus `out/index.json`
71
+ * listing them, for a site to publish unchanged. A kind whose owner is not installed is listed as
72
+ * missing, never written from a guess, and one whose owner ships an invalid manifest is listed with
73
+ * `invalid` and not written.
74
+ */
75
+ export function exportManifests(out, opts = {}) {
76
+ const index = [];
77
+ for (const k of KINDS) {
78
+ const { dir, owner } = manifestHome(k, opts);
79
+ // An invalid manifest is never published: the page a record links to must describe its rules.
80
+ const invalid = dir ? manifestFindings(k, opts).map((f) => f.message) : [];
81
+ const versions = dir && !invalid.length ? implementedVersions(k, opts) : [];
82
+ for (const v of versions) {
83
+ mkdirSync(join(out, k.kind), { recursive: true });
84
+ writeFileSync(join(out, k.kind, `v${v}.json`), readFileSync(join(dir, `v${v}.json`)));
85
+ }
86
+ index.push({ kind: k.kind, owner, latest: `${STANDARD_BASE}${k.kind}`, versions: versions.map((v) => `${STANDARD_BASE}${k.kind}/v${v}`), missing: !dir, ...(invalid.length ? { invalid } : {}) });
87
+ }
88
+ mkdirSync(out, { recursive: true });
89
+ writeFileSync(join(out, 'index.json'), JSON.stringify({ manifest_version: 1, standards: index }, null, 2) + '\n');
90
+ return index;
91
+ }
92
+
93
+ // Key order is not meaning: compare manifests as canonical JSON.
94
+ const canon = (v) => (Array.isArray(v) ? v.map(canon) : v && typeof v === 'object' ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, canon(v[k])])) : v);
95
+ export const sameManifest = (a, b) => JSON.stringify(canon(a)) === JSON.stringify(canon(b));
96
+
97
+ /**
98
+ * Prove every standard a workspace's records name is live, and says what the installed owner
99
+ * enforces. `seen` maps kind -> Set of standard URLs found in its records.
100
+ * @returns {Promise<Array<{kind, level, code, path, message}>>}
101
+ */
102
+ export async function checkOnline(seen, { fetch: get = globalThis.fetch, packagesDir, timeoutMs = 15000 } = {}) {
103
+ const findings = [];
104
+ for (const k of KINDS) {
105
+ for (const url of seen.get(k.kind) ?? []) {
106
+ const m = url.match(/\/v(\d+)$/);
107
+ const page = await probe(get, url, timeoutMs);
108
+ if (!page.ok) { findings.push({ kind: k.kind, level: 'error', code: 'standard:unreachable', path: '', message: `${url} answered ${page.status}; every record naming it points at nothing` }); continue; }
109
+ if (!m) continue;
110
+ const json = await probe(get, `${url}.json`, timeoutMs, true);
111
+ if (!json.ok) { findings.push({ kind: k.kind, level: 'error', code: 'standard:unreachable', path: '', message: `${url}.json answered ${json.status}; the standard has no published manifest` }); continue; }
112
+ const local = readManifest(k, Number(m[1]), { packagesDir });
113
+ if (!local) continue;
114
+ if (!sameManifest(json.body, local)) findings.push({ kind: k.kind, level: 'error', code: 'standard:drift', path: '', message: `the manifest published at ${url}.json differs from the one ${manifestHome(k, { packagesDir }).owner} ships; republish the owner's file` });
115
+ }
116
+ }
117
+ return findings;
118
+ }
119
+
120
+ async function probe(get, url, timeoutMs, asJson = false) {
121
+ try {
122
+ const r = await get(url, { redirect: 'follow', signal: AbortSignal.timeout(timeoutMs) });
123
+ if (r.status !== 200) return { ok: false, status: r.status };
124
+ if (!asJson) return { ok: true, status: 200 };
125
+ try { return { ok: true, status: 200, body: await r.json() }; } catch { return { ok: false, status: '200 with a body that is not JSON' }; }
126
+ } catch (e) { return { ok: false, status: `no answer (${e.name})` }; }
127
+ }
@@ -0,0 +1,147 @@
1
+ // Asking each kind's own package to check its records, and reading back what it said.
2
+ //
3
+ // Every package is asked through its documented command line, with --json, rather than through
4
+ // its internals: the CLI is the contract each package promises to keep, so this package keeps
5
+ // working across their releases. Their outputs differ in shape (rule or code or check, severity
6
+ // or level), and normalize() is the only place that knows it.
7
+ import { createRequire } from 'node:module';
8
+ import { spawnSync } from 'node:child_process';
9
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { dirname, join, relative } from 'node:path';
12
+
13
+ const require = createRequire(import.meta.url);
14
+ // This package's own root. In the hypercontext repository every record package is a sibling
15
+ // workspace (packages/<repo>), so a checkout finds them with no install and no flag.
16
+ const SELF_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
17
+
18
+ const nameAt = (dir) => { try { return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).name; } catch { return null; } };
19
+
20
+ /**
21
+ * Where a package is, in this order:
22
+ * 1. `packagesDir/<repo>` when a folder of checkouts is named (the option or HYPERCONTEXT_PACKAGES).
23
+ * Named means only: nothing else is consulted, so a caller can say exactly which packages run.
24
+ * 2. node resolution from this package, which finds an installed package and, in the hypercontext
25
+ * repository after `npm install`, the workspace links.
26
+ * 3. `<repo>` beside this package, the layout of the hypercontext repository, so an un-installed
27
+ * checkout still finds its sibling packages.
28
+ * @returns {string|null} the package root
29
+ */
30
+ export function resolvePackage(pkg, { packagesDir = process.env.HYPERCONTEXT_PACKAGES } = {}) {
31
+ if (packagesDir) {
32
+ const dir = join(packagesDir, pkg.repo);
33
+ return nameAt(dir) === pkg.name ? dir : null;
34
+ }
35
+ try {
36
+ let dir = dirname(require.resolve(pkg.name));
37
+ for (let i = 0; i < 6; i++) {
38
+ if (nameAt(dir) === pkg.name) return realpathSync(dir);
39
+ dir = dirname(dir);
40
+ }
41
+ } catch { /* not installed; try the sibling workspace */ }
42
+ const sibling = join(dirname(SELF_ROOT), pkg.repo);
43
+ return nameAt(sibling) === pkg.name ? sibling : null;
44
+ }
45
+
46
+ export function packageVersion(dir) {
47
+ try { return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).version ?? ''; } catch { return ''; }
48
+ }
49
+
50
+ /** Run a package's command with --json and parse what it printed. */
51
+ export function runJson(dir, bin, args, { cwd } = {}) {
52
+ const r = spawnSync(process.execPath, [join(dir, bin), ...args, '--json'], { cwd, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
53
+ if (r.error) return { ok: false, error: r.error.message };
54
+ const out = (r.stdout ?? '').trim();
55
+ if (!out) return { ok: false, error: (r.stderr ?? '').trim().split('\n')[0] || `exit ${r.status}`, status: r.status };
56
+ try { return { ok: true, json: JSON.parse(out), status: r.status }; }
57
+ catch { return { ok: false, error: `printed something that is not JSON (exit ${r.status})`, status: r.status }; }
58
+ }
59
+
60
+ const LEVEL = { error: 'error', err: 'error', warn: 'warn', warning: 'warn', info: 'info', ok: 'ok' };
61
+
62
+ /**
63
+ * One package finding in the shape this package reports: {level, code, path, message}.
64
+ * `where` maps the package's own pointer (a slug, an id, a piece path) to a workspace path.
65
+ */
66
+ export function normalize(f, where = (x) => x) {
67
+ const level = LEVEL[String(f.level ?? f.severity ?? '').toLowerCase()] ?? 'warn';
68
+ const code = String(f.code ?? f.rule ?? f.check ?? 'finding');
69
+ const ptr = f.path ?? f.file ?? f.record ?? f.piece ?? f.slug ?? f.id ?? '';
70
+ const message = String(f.message ?? f.line ?? '');
71
+ return { level, code, path: ptr ? where(String(ptr)) : '', message, ...(f.fix ? { fix: String(f.fix) } : {}) };
72
+ }
73
+
74
+ const findingsOf = (json) => (Array.isArray(json) ? json : Array.isArray(json?.findings) ? json.findings : []);
75
+
76
+ /**
77
+ * The per-kind calls. Each returns {ran, findings, note}. `ws` is absolute; paths returned are
78
+ * relative to it. A kind's call covers every record of that kind in the workspace.
79
+ */
80
+ export const LINTS = {
81
+ self: (dir, bin, ws) => {
82
+ const r = runJson(dir, bin, ['lint', '--workspace', ws]);
83
+ return r.ok ? { ran: true, findings: findingsOf(r.json).map((f) => normalize(f)) } : { ran: false, note: r.error };
84
+ },
85
+ friend: (dir, bin, ws) => {
86
+ const r = runJson(dir, bin, ['lint', '--people', join(ws, 'people')]);
87
+ return r.ok ? { ran: true, findings: findingsOf(r.json).map((f) => normalize({ ...f, path: undefined, slug: f.slug }, (s) => `people/${s}/friend.md`)) } : { ran: false, note: r.error };
88
+ },
89
+ group: (dir, bin, ws) => {
90
+ if (!existsSync(join(ws, 'people/groups'))) return { ran: true, findings: [] };
91
+ const r = runJson(dir, bin, ['lint', join(ws, 'people/groups')]);
92
+ return r.ok ? { ran: true, findings: findingsOf(r.json).map((f) => normalize(f, (s) => `people/groups/${s}/group.md`)) } : { ran: false, note: r.error };
93
+ },
94
+ superproject: (dir, bin, ws, { roots }) => {
95
+ const findings = [];
96
+ for (const root of roots) {
97
+ const r = runJson(dir, bin, ['lint', '--root', join(ws, root)]);
98
+ if (!r.ok) return { ran: false, note: r.error };
99
+ // A nested superproject comes back as an absolute path rather than a slug.
100
+ const where = (s) => (s.startsWith('/') ? relative(ws, s) : `${root}/${s}`);
101
+ for (const f of findingsOf(r.json)) if (f.level !== 'ok') findings.push(normalize(f, where));
102
+ }
103
+ return { ran: true, findings };
104
+ },
105
+ superobjective: (dir, bin, ws, { homes }) => {
106
+ const findings = [];
107
+ for (const home of homes) {
108
+ const r = runJson(dir, bin, ['lint', join(ws, home)]);
109
+ if (!r.ok) return { ran: false, note: r.error };
110
+ for (const f of findingsOf(r.json)) findings.push(normalize(f, (s) => `${home}/${s}`));
111
+ }
112
+ return { ran: true, findings };
113
+ },
114
+ // One call per documents home. A home the package cannot read is one finding against that
115
+ // home, and the other homes are still checked.
116
+ piece: (dir, bin, ws, { homes }) => {
117
+ const findings = [];
118
+ for (const home of homes) {
119
+ const r = runJson(dir, bin, ['lint', join(ws, home)]);
120
+ if (!r.ok) { findings.push({ level: 'error', code: 'validator:failed', path: home, message: `piece-md could not check this folder: ${r.error}` }); continue; }
121
+ for (const f of findingsOf(r.json)) findings.push(normalize(f, (s) => `${home}/${s}`));
122
+ }
123
+ return { ran: true, findings };
124
+ },
125
+ // A conversation is checked in-process through the package's codec: one process per file
126
+ // would cost minutes on a workspace that holds thousands.
127
+ conversation: async (dir, _bin, ws, { records, read }) => {
128
+ let mod;
129
+ try { mod = await import(new URL(`file://${join(dir, 'index.mjs')}`)); } catch (e) { return { ran: false, note: e.message }; }
130
+ if (typeof mod.parseConversationHeader !== 'function' || typeof mod.validateConversation !== 'function') {
131
+ return { ran: false, note: 'this version exports no parseConversationHeader/validateConversation' };
132
+ }
133
+ const findings = [];
134
+ for (const rel of records) {
135
+ let parsed;
136
+ try { parsed = mod.parseConversationHeader(Buffer.from(read(join(ws, rel)))); }
137
+ catch (e) { findings.push({ level: 'error', code: e.code ?? 'header:unreadable', path: rel, message: e.message }); continue; }
138
+ const folderId = rel.split('/').at(-2);
139
+ const diagnostics = parsed.header ? mod.validateConversation(parsed.header, { folderId }) : parsed.diagnostics;
140
+ for (const d of diagnostics ?? []) {
141
+ const f = normalize({ level: d.severity ?? 'error', code: d.code, message: `${d.field ? `${d.field}: ` : ''}${d.message ?? ''}` });
142
+ findings.push({ ...f, path: rel });
143
+ }
144
+ }
145
+ return { ran: true, findings };
146
+ },
147
+ };
package/src/render.mjs ADDED
@@ -0,0 +1,47 @@
1
+ // The check as text a person reads: one row per kind, then the findings that need someone.
2
+ const pad = (s, n) => String(s).padEnd(n);
3
+ const lpad = (s, n) => String(s).padStart(n);
4
+
5
+ export function renderReport(r, { limit = 40 } = {}) {
6
+ const lines = [];
7
+ const width = Math.max(4, ...r.rows.map((x) => x.kind.length));
8
+ lines.push(`${pad('kind', width)} ${lpad('records', 7)} ${lpad('names standard', 14)} ${lpad('context', 7)} validated by`);
9
+ for (const row of r.rows) {
10
+ const v = row.validator ? `${row.validator}${row.validatorNote ? ` (${row.validatorNote})` : ''}` : `none: ${row.validatorNote}`;
11
+ lines.push(`${pad(row.kind, width)} ${lpad(row.records, 7)} ${lpad(`${row.selfDocumenting}/${row.records}`, 14)} ${lpad(`${row.contextComplete}/${row.records}`, 7)} ${v}`);
12
+ }
13
+ lines.push('');
14
+ const errors = r.findings.filter((f) => f.level === 'error' && !f.known);
15
+ const warns = r.findings.filter((f) => f.level === 'warn');
16
+ const byCode = (list) => {
17
+ const m = new Map();
18
+ for (const f of list) { const k = `${f.kind} ${f.code}`; m.set(k, (m.get(k) ?? 0) + 1); }
19
+ return [...m].sort((a, b) => b[1] - a[1]);
20
+ };
21
+ if (r.known) lines.push(`${r.known} error(s) predate the baseline and are tolerated.`);
22
+ if (errors.length) {
23
+ lines.push(`${errors.length} error(s)${r.known != null && r.known ? ' new since the baseline' : ''}:`);
24
+ for (const [k, n] of byCode(errors)) lines.push(` ${lpad(n, 5)} ${k}`);
25
+ lines.push('');
26
+ for (const f of errors.slice(0, limit)) lines.push(` error ${f.kind} ${f.code} ${f.path || '(workspace)'} ${f.message}`);
27
+ if (errors.length > limit) lines.push(` ... and ${errors.length - limit} more (--json lists every one)`);
28
+ }
29
+ if (warns.length) {
30
+ lines.push(`${warns.length} warning(s):`);
31
+ for (const [k, n] of byCode(warns)) lines.push(` ${lpad(n, 5)} ${k}`);
32
+ }
33
+ lines.push('');
34
+ lines.push(r.ok ? 'ok: every record names its standard and passes its package.' : 'not ok: fix the errors above, or run `hypercontext stamp --apply` for the missing standards.');
35
+ return lines.join('\n');
36
+ }
37
+
38
+ export function renderStamp(s) {
39
+ const lines = [s.apply ? 'Stamped:' : 'Would stamp (dry run; --apply writes):'];
40
+ for (const k of s.kinds) {
41
+ if (!k.missing) continue;
42
+ const n = k.changed != null ? `${k.changed} of ${k.missing}` : `${k.missing}`;
43
+ lines.push(` ${k.kind}: ${n} via ${k.via}${k.note ? `; ${k.note}` : ''}${k.reasons ? `; skipped ${Object.entries(k.reasons).map(([r, c]) => `${c} (${r})`).join(', ')}` : ''}`);
44
+ }
45
+ if (lines.length === 1) lines.push(' nothing: every record already names its standard.');
46
+ return lines.join('\n');
47
+ }
@@ -0,0 +1,56 @@
1
+ // The one rule this package owns: a record names the standard it follows.
2
+ //
3
+ // `standard:` is the field, holding the one page for the record's kind. It is what lets a reader
4
+ // with no other context (a stranger, a fresh agent, a different tool in five years) open the file
5
+ // and find the rules it was written to, so a record without it is not self-documenting however
6
+ // complete the rest of it is. The shared context keys (id, summary, read_when, as_of, keep) are
7
+ // counted beside it and never refused here: a kind's own package decides whether an empty
8
+ // summary is a defect, and most already say so (friend-md calls it "untended").
9
+ import { readHeader, stampStandard } from '@supersuit/hypercontext-core';
10
+
11
+ import { versionOf, CURRENT_VERSION } from './kinds.mjs';
12
+
13
+ export const CODE = 'self-doc:missing';
14
+ export const VERSION_CODE = 'self-doc:version';
15
+
16
+ /** Read the `standard` a record carries, from its YAML header or its JSON. */
17
+ export function standardOf(text, format = 'md') {
18
+ if (format === 'json') {
19
+ try { const j = JSON.parse(text); return { header: true, data: j ?? {}, standard: typeof j?.standard === 'string' ? j.standard : '' }; }
20
+ catch { return { header: false, data: {}, standard: '' }; }
21
+ }
22
+ const h = readHeader(text);
23
+ return { header: h.raw !== '', data: h.data, standard: typeof h.data.standard === 'string' ? h.data.standard : '' };
24
+ }
25
+
26
+ const filled = (v) => (Array.isArray(v) ? v.length > 0 : typeof v === 'string' ? v.trim() !== '' : v != null);
27
+
28
+ /**
29
+ * Judge one record against the shared property.
30
+ * @param {string} text the file
31
+ * @param {import('./kinds.mjs').Kind} kind
32
+ * @param {{alsoAccept?: string[], versions?: number[]}} [opts] other standards a record of this
33
+ * kind may rightly name, and the versions of its own standard the validator implements
34
+ * @returns {{selfDocumenting: boolean, code: string|null, contextComplete: boolean, missingContext: string[], problem: string|null}}
35
+ */
36
+ export function judge(text, kind, { alsoAccept = [], versions = [CURRENT_VERSION] } = {}) {
37
+ const got = standardOf(text, kind.format);
38
+ const missingContext = kind.context.filter((k) => !filled(got.data[k]));
39
+ const contextComplete = got.header && missingContext.length === 0;
40
+ let problem = null, code = CODE;
41
+ const v = versionOf(kind.kind, got.standard);
42
+ if (!got.header) problem = kind.format === 'json' ? 'not valid JSON, so it cannot name its standard' : 'has no header, so it cannot name its standard';
43
+ else if (!got.standard) problem = `names no standard; it should carry standard: ${kind.standard}`;
44
+ else if (alsoAccept.includes(got.standard)) problem = null;
45
+ else if (v === 0) problem = `names ${got.standard}, which predates versions; it should name ${kind.standard} (stamp upgrades it)`;
46
+ else if (v === null) problem = `names ${got.standard}; a ${kind.kind} record names ${kind.standard}`;
47
+ else if (!versions.includes(v)) { problem = `names version ${v} of the ${kind.kind} standard, which the validator here does not implement (it implements ${versions.map((x) => `v${x}`).join(', ')})`; code = VERSION_CODE; }
48
+ return { selfDocumenting: problem === null, code: problem === null ? null : code, contextComplete, missingContext, problem };
49
+ }
50
+
51
+ // A bare one-file piece lives among the documents and rightly names the piece standard.
52
+ export const acceptedFor = (kindName, data, standards) =>
53
+ kindName === 'document' && data?.kind === 'piece' ? [standards.piece] : [];
54
+
55
+ // The byte-preserving stamp is the same for every kind and every package: it lives in core.
56
+ export { stampStandard };