@supersuit/hypercontext 0.0.0-stage → 0.2.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,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 };
package/src/stamp.mjs ADDED
@@ -0,0 +1,96 @@
1
+ // The backfill: give every existing record the `standard:` it is missing.
2
+ //
3
+ // A kind with a package is stamped BY ITS PACKAGE, through the command that package ships for
4
+ // it, because the package knows its own key order and every other thing its writers keep; this
5
+ // file only calls it. A kind with no package is stamped here, by the one byte-preserving edit in
6
+ // selfdoc.mjs. A kind marked `stampable: false` is never touched, and the report says why.
7
+ //
8
+ // Dry run unless `apply`. Never commits.
9
+ import { readFileSync, writeFileSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { spawnSync } from 'node:child_process';
12
+ import { KINDS, bareStandard } from './kinds.mjs';
13
+ import { findRecords, superprojectRoots, documentHomes, fsDefault } from './find.mjs';
14
+ import { judge, acceptedFor, standardOf, stampStandard } from './selfdoc.mjs';
15
+ import { resolvePackage, packageVersion } from './packages.mjs';
16
+
17
+ const STANDARDS = Object.fromEntries(KINDS.map((k) => [k.kind, k.standard]));
18
+
19
+ // Each package's own backfill command, as its README documents it. `apply` adds the write flag.
20
+ export const PACKAGE_STAMPS = {
21
+ self: (ws, apply) => [['stamp-standard', '--workspace', ws, ...(apply ? ['--write'] : [])]],
22
+ friend: (ws, apply) => [['stamp', '--people', join(ws, 'people'), ...(apply ? ['--apply'] : [])]],
23
+ group: (ws, apply) => [['migrate', join(ws, 'people/groups'), ...(apply ? ['--apply'] : [])]],
24
+ // transcript-md takes its mutation lock and checks each file's hash before it writes.
25
+ piece: (ws, apply, { homes }) => homes.map((h) => ['stamp', join(ws, h), ...(apply ? ['--apply'] : [])]),
26
+ conversation: (ws, apply) => [['stamp-standard', '--workspace', ws, ...(apply ? ['--apply'] : [])]],
27
+ superproject: (ws, apply, { roots }) => roots.map((r) => ['migrate', '--root', join(ws, r), '--stamp-standard', ...(apply ? ['--apply'] : [])]),
28
+ document: (ws, apply, { homes }) => (homes.length ? [['stamp', ...homes.map((h) => join(ws, h)), ...(apply ? ['--apply'] : [])]] : []),
29
+ checkpoint: (ws, apply) => [['stamp', join(ws, 'checkpoints'), ...(apply ? ['--apply'] : [])]],
30
+ // One run stamps the maps and the catalog; it skips a catalog with no header and says why.
31
+ hdsop: (ws, apply) => [['stamp', join(ws, 'workflows'), ...(apply ? ['--apply'] : [])]],
32
+ // A recurrent's fields move from body labels into its header in the same edit that names its
33
+ // standard, so there is never a second copy of a field.
34
+ recurrent: (ws, apply) => [['migrate', join(ws, 'recurrents'), ...(apply ? ['--apply'] : [])]],
35
+ 'tile-app': (ws, apply) => [['stamp', ws, ...(apply ? ['--apply'] : [])]],
36
+ };
37
+
38
+ // What a package's stamp printed that a person needs: every line naming a record it skipped
39
+ // (with its reason) and its closing count. Paths are made workspace-relative.
40
+ function said(stdout, ws) {
41
+ const lines = (stdout ?? '').split('\n').map((l) => l.trimEnd()).filter(Boolean);
42
+ if (!lines.length || lines[0].startsWith('{') || lines[0].startsWith('[')) return { skipped: [], summary: '' };
43
+ const rel = (l) => l.split(`${ws}/`).join('');
44
+ return { skipped: lines.filter((l) => /^skipped\s/.test(l)).map(rel), summary: rel(lines.at(-1)) };
45
+ }
46
+
47
+ /**
48
+ * @returns {Promise<{apply: boolean, kinds: Array<{kind, missing, via, changed?, skipped?, note?}>}>}
49
+ */
50
+ export async function stamp(workspace, { kinds, apply = false, packagesDir, fs = fsDefault, registry = KINDS } = {}) {
51
+ const out = [];
52
+ const roots = superprojectRoots(workspace, fs);
53
+ const done = new Set();
54
+ const missingOf = (k, records) => records.filter((rel) => {
55
+ const text = readFileSync(join(workspace, rel), 'utf8');
56
+ return !judge(text, k, { alsoAccept: acceptedFor(k.kind, standardOf(text, k.format).data, STANDARDS) }).selfDocumenting;
57
+ });
58
+ for (const k of registry.filter((x) => !kinds || kinds.includes(x.kind))) {
59
+ const records = findRecords(workspace, k.kind, fs);
60
+ const missing = missingOf(k, records);
61
+ const row = { kind: k.kind, records: records.length, missing: missing.length };
62
+ if (!missing.length) { out.push({ ...row, via: 'nothing to do' }); continue; }
63
+ if (k.stampable === false) { out.push({ ...row, via: 'not stamped', note: k.why }); continue; }
64
+
65
+ const lead = k.sharesLint ?? k.kind;
66
+ if (k.pkg && PACKAGE_STAMPS[lead]) {
67
+ if (done.has(lead)) { out.push({ ...row, via: `${k.pkg.name}, with ${lead}` }); continue; }
68
+ done.add(lead);
69
+ const dir = resolvePackage(k.pkg, { packagesDir });
70
+ if (!dir) { out.push({ ...row, via: 'not stamped', note: `${k.pkg.name} is not installed` }); continue; }
71
+ const runs = PACKAGE_STAMPS[lead](workspace, apply, { roots, homes: documentHomes(workspace, fs) }).map((args) => {
72
+ const r = spawnSync(process.execPath, [join(dir, k.pkg.bin), ...args], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
73
+ return { args: args.join(' '), status: r.status, stderr: (r.stderr ?? '').trim().split('\n')[0] ?? '', ...said(r.stdout, workspace) };
74
+ });
75
+ // Exit 2 is a usage error in every one of these packages: the command this file names is
76
+ // not one that version knows, which is a version mismatch, never a record problem.
77
+ const failed = runs.filter((r) => r.status === 2 || r.status === null);
78
+ const skipped = runs.flatMap((r) => r.skipped);
79
+ const summary = runs.map((r) => r.summary).filter(Boolean);
80
+ out.push({ ...row, via: `${k.pkg.name}@${packageVersion(dir)}`, ...(failed.length ? { note: `did not run: ${failed.map((f) => `${f.args} (${f.stderr})`).join('; ')}` } : {}),
81
+ ...(summary.length ? { said: summary } : {}), ...(skipped.length ? { skippedLines: skipped } : {}) });
82
+ continue;
83
+ }
84
+ if (k.pkg) { out.push({ ...row, via: 'not stamped', note: `${k.pkg.name} ships no stamp this package knows how to call` }); continue; }
85
+
86
+ let changed = 0; const reasons = {};
87
+ for (const rel of missing) {
88
+ const p = join(workspace, rel);
89
+ const r = stampStandard(readFileSync(p, 'utf8'), k.standard, bareStandard(k.kind));
90
+ if (r.changed) { changed++; if (apply) writeFileSync(p, r.text); }
91
+ else reasons[r.reason] = (reasons[r.reason] ?? 0) + 1;
92
+ }
93
+ out.push({ ...row, via: '@supersuit/hypercontext', changed, skipped: missing.length - changed, ...(Object.keys(reasons).length ? { reasons } : {}) });
94
+ }
95
+ return { apply, kinds: out };
96
+ }