@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.
- package/CHANGELOG.md +27 -0
- package/LICENSE +21 -0
- package/README.md +103 -2
- package/bin/hypercontext.mjs +91 -0
- package/docs/STANDARDS.md +86 -0
- package/package.json +17 -4
- package/src/baseline.mjs +49 -0
- package/src/check.mjs +114 -0
- package/src/find.mjs +157 -0
- package/src/index.mjs +9 -0
- package/src/kinds.mjs +77 -0
- package/src/manifest.mjs +129 -0
- package/src/packages.mjs +175 -0
- package/src/render.mjs +51 -0
- package/src/selfdoc.mjs +56 -0
- package/src/stamp.mjs +96 -0
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,77 @@
|
|
|
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
|
+
// Every kind has its package (since 0.2.0). A kind added with `pkg: null` would still be checked
|
|
12
|
+
// for the shared property, and the report would say in one line that nothing checks its shape,
|
|
13
|
+
// so the gap is visible from the 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
|
+
// One package owns both the workflow maps and the catalog that scores them; one run checks both.
|
|
41
|
+
const HDSOP = Object.freeze({ name: '@supersuit/hdsop-md', repo: 'hdsop-md', bin: 'bin/hdsop-md.mjs' });
|
|
42
|
+
|
|
43
|
+
/** @type {readonly Kind[]} */
|
|
44
|
+
export const KINDS = Object.freeze([
|
|
45
|
+
{ kind: 'self', label: 'the owner, self/self.md', standard: url('self'), format: 'md', context: CONTEXT,
|
|
46
|
+
pkg: { name: '@supersuit/self-md', repo: 'self-md', bin: 'bin/self-md.mjs' } },
|
|
47
|
+
{ kind: 'friend', label: 'people, people/<slug>/friend.md', standard: url('friend'), format: 'md', context: CONTEXT,
|
|
48
|
+
pkg: { name: '@supersuit/friend-md', repo: 'friend-md', bin: 'bin/friend-md.mjs' } },
|
|
49
|
+
{ kind: 'group', label: 'groups, people/groups/<id>/group.md', standard: url('group'), format: 'md', context: [],
|
|
50
|
+
pkg: { name: '@supersuit/group-md', repo: 'group-md', bin: 'bin/group-md.mjs' } },
|
|
51
|
+
{ kind: 'superproject', label: 'superprojects, SUPERPROJECT.md', standard: url('superproject'), format: 'md', context: [],
|
|
52
|
+
pkg: SUPERPROJECTS },
|
|
53
|
+
{ kind: 'state', label: 'superproject state, STATE.md', standard: url('state'), format: 'md', context: [],
|
|
54
|
+
pkg: SUPERPROJECTS, sharesLint: 'superproject' },
|
|
55
|
+
{ kind: 'atomic-action-map', label: 'atomic action maps, ATOMIC-ACTION-MAP.md', standard: url('atomic-action-map'), format: 'md', context: [],
|
|
56
|
+
pkg: SUPERPROJECTS, sharesLint: 'superproject' },
|
|
57
|
+
{ kind: 'superobjective', label: 'superobjectives, superobjective.md', standard: url('superobjective'), format: 'md', context: CONTEXT,
|
|
58
|
+
pkg: { name: '@supersuit/superobjective', repo: 'superobjective', bin: 'bin/superobjective.mjs' } },
|
|
59
|
+
{ kind: 'piece', label: 'pieces, piece.md', standard: url('piece'), format: 'md', context: CONTEXT,
|
|
60
|
+
pkg: { name: '@supersuit/piece-md', repo: 'piece-md', bin: 'bin/piece-md.mjs' } },
|
|
61
|
+
{ kind: 'conversation', label: 'conversations, transcript.md', standard: url('conversation'), format: 'md', context: [],
|
|
62
|
+
pkg: { name: '@supersuit/transcript-md', repo: 'transcript-md', bin: 'lib/freedom-conversations-cli.mjs' } },
|
|
63
|
+
{ kind: 'document', label: 'documents, documents/*.md', standard: url('document'), format: 'md', context: [],
|
|
64
|
+
pkg: { name: '@supersuit/document-md', repo: 'document-md', bin: 'bin/document-md.mjs' } },
|
|
65
|
+
{ kind: 'checkpoint', label: 'checkpoints, checkpoints/*.md', standard: url('checkpoint'), format: 'md', context: [],
|
|
66
|
+
pkg: { name: '@supersuit/checkpoint-md', repo: 'checkpoint-md', bin: 'bin/checkpoint-md.mjs' } },
|
|
67
|
+
{ kind: 'hdsop', label: 'workflow maps, workflows/*.md', standard: url('hdsop'), format: 'md', context: [],
|
|
68
|
+
pkg: HDSOP },
|
|
69
|
+
{ kind: 'workflow-catalog', label: 'the workflow catalog, workflows/CATALOG.md', standard: url('workflow-catalog'), format: 'md', context: [],
|
|
70
|
+
pkg: HDSOP, sharesLint: 'hdsop' },
|
|
71
|
+
{ kind: 'recurrent', label: 'recurrents, recurrents/*.md', standard: url('recurrent'), format: 'md', context: [],
|
|
72
|
+
pkg: { name: '@supersuit/recurrent-md', repo: 'recurrent-md', bin: 'bin/recurrent-md.mjs' } },
|
|
73
|
+
{ kind: 'tile-app', label: 'tile apps, <app>/app.json', standard: url('tile-app'), format: 'json', context: [],
|
|
74
|
+
pkg: { name: '@supersuit/tile-app-md', repo: 'tile-app-md', bin: 'bin/tile-app-md.mjs' } },
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
export const byKind = (k) => KINDS.find((x) => x.kind === k);
|
package/src/manifest.mjs
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
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
|
+
// A kind with no package and no manifest here has none at all: missing, never a crash.
|
|
27
|
+
const own = join(OWN, kind.kind);
|
|
28
|
+
return { dir: existsSync(own) ? own : null, owner: '@supersuit/hypercontext' };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Every version a kind's owner ships a manifest for; [current] when none can be read. */
|
|
32
|
+
export function implementedVersions(kind, opts = {}) {
|
|
33
|
+
const { dir } = manifestHome(kind, opts);
|
|
34
|
+
if (!dir) return [CURRENT_VERSION];
|
|
35
|
+
const got = readdirSync(dir).map((n) => n.match(/^v(\d+)\.json$/)).filter(Boolean).map((m) => Number(m[1])).sort((a, b) => a - b);
|
|
36
|
+
return got.length ? got : [CURRENT_VERSION];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A kind's manifest for one version, from its owner, or null. */
|
|
40
|
+
export function readManifest(kind, version = CURRENT_VERSION, opts = {}) {
|
|
41
|
+
const { dir } = manifestHome(kind, opts);
|
|
42
|
+
if (!dir) return null;
|
|
43
|
+
try { return JSON.parse(readFileSync(join(dir, `v${version}.json`), 'utf8')); } catch { return null; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Every manifest a kind's owner ships, judged by @supersuit/hypercontext-core's validateManifest
|
|
48
|
+
* (STANDARDS.md section 2), plus the two things only the file's place can say: it is the manifest
|
|
49
|
+
* of this kind, and of the version its file name says. One error finding per bad file.
|
|
50
|
+
* @returns {Array<{kind, level, code, path, message}>}
|
|
51
|
+
*/
|
|
52
|
+
export function manifestFindings(kind, opts = {}) {
|
|
53
|
+
const { dir, owner } = manifestHome(kind, opts);
|
|
54
|
+
if (!dir) return [];
|
|
55
|
+
const out = [];
|
|
56
|
+
for (const name of readdirSync(dir).filter((n) => /^v\d+\.json$/.test(n)).sort()) {
|
|
57
|
+
const rel = `standards/${kind.kind}/${name}`;
|
|
58
|
+
const bad = (why) => out.push({ kind: kind.kind, level: 'error', code: MANIFEST_CODE, path: '', message: `${owner} ships ${rel}, ${why}` });
|
|
59
|
+
let m;
|
|
60
|
+
try { m = JSON.parse(readFileSync(join(dir, name), 'utf8')); } catch { bad('which is not JSON'); continue; }
|
|
61
|
+
const { problems } = validateManifest(m);
|
|
62
|
+
if (m && typeof m === 'object') {
|
|
63
|
+
if (m.kind !== kind.kind) problems.push(`its kind is ${JSON.stringify(m.kind)}, not ${kind.kind}`);
|
|
64
|
+
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`);
|
|
65
|
+
}
|
|
66
|
+
if (problems.length) bad(`which is not a valid manifest: ${problems.join('; ')}`);
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Copy every kind's manifests from their owners into `out/<kind>/v<N>.json`, plus `out/index.json`
|
|
73
|
+
* listing them, for a site to publish unchanged. A kind whose owner is not installed is listed as
|
|
74
|
+
* missing, never written from a guess, and one whose owner ships an invalid manifest is listed with
|
|
75
|
+
* `invalid` and not written.
|
|
76
|
+
*/
|
|
77
|
+
export function exportManifests(out, opts = {}) {
|
|
78
|
+
const index = [];
|
|
79
|
+
for (const k of KINDS) {
|
|
80
|
+
const { dir, owner } = manifestHome(k, opts);
|
|
81
|
+
// An invalid manifest is never published: the page a record links to must describe its rules.
|
|
82
|
+
const invalid = dir ? manifestFindings(k, opts).map((f) => f.message) : [];
|
|
83
|
+
const versions = dir && !invalid.length ? implementedVersions(k, opts) : [];
|
|
84
|
+
for (const v of versions) {
|
|
85
|
+
mkdirSync(join(out, k.kind), { recursive: true });
|
|
86
|
+
writeFileSync(join(out, k.kind, `v${v}.json`), readFileSync(join(dir, `v${v}.json`)));
|
|
87
|
+
}
|
|
88
|
+
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 } : {}) });
|
|
89
|
+
}
|
|
90
|
+
mkdirSync(out, { recursive: true });
|
|
91
|
+
writeFileSync(join(out, 'index.json'), JSON.stringify({ manifest_version: 1, standards: index }, null, 2) + '\n');
|
|
92
|
+
return index;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Key order is not meaning: compare manifests as canonical JSON.
|
|
96
|
+
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);
|
|
97
|
+
export const sameManifest = (a, b) => JSON.stringify(canon(a)) === JSON.stringify(canon(b));
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Prove every standard a workspace's records name is live, and says what the installed owner
|
|
101
|
+
* enforces. `seen` maps kind -> Set of standard URLs found in its records.
|
|
102
|
+
* @returns {Promise<Array<{kind, level, code, path, message}>>}
|
|
103
|
+
*/
|
|
104
|
+
export async function checkOnline(seen, { fetch: get = globalThis.fetch, packagesDir, timeoutMs = 15000 } = {}) {
|
|
105
|
+
const findings = [];
|
|
106
|
+
for (const k of KINDS) {
|
|
107
|
+
for (const url of seen.get(k.kind) ?? []) {
|
|
108
|
+
const m = url.match(/\/v(\d+)$/);
|
|
109
|
+
const page = await probe(get, url, timeoutMs);
|
|
110
|
+
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; }
|
|
111
|
+
if (!m) continue;
|
|
112
|
+
const json = await probe(get, `${url}.json`, timeoutMs, true);
|
|
113
|
+
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; }
|
|
114
|
+
const local = readManifest(k, Number(m[1]), { packagesDir });
|
|
115
|
+
if (!local) continue;
|
|
116
|
+
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` });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return findings;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function probe(get, url, timeoutMs, asJson = false) {
|
|
123
|
+
try {
|
|
124
|
+
const r = await get(url, { redirect: 'follow', signal: AbortSignal.timeout(timeoutMs) });
|
|
125
|
+
if (r.status !== 200) return { ok: false, status: r.status };
|
|
126
|
+
if (!asJson) return { ok: true, status: 200 };
|
|
127
|
+
try { return { ok: true, status: 200, body: await r.json() }; } catch { return { ok: false, status: '200 with a body that is not JSON' }; }
|
|
128
|
+
} catch (e) { return { ok: false, status: `no answer (${e.name})` }; }
|
|
129
|
+
}
|
package/src/packages.mjs
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
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
|
+
// document-md takes every documents home in one call and names each finding by its home plus
|
|
126
|
+
// the document's path under it.
|
|
127
|
+
document: (dir, bin, ws, { homes }) => {
|
|
128
|
+
if (!homes.length) return { ran: true, findings: [] };
|
|
129
|
+
const r = runJson(dir, bin, ['lint', ...homes.map((h) => join(ws, h))]);
|
|
130
|
+
return r.ok
|
|
131
|
+
? { ran: true, findings: findingsOf(r.json).map((f) => normalize({ ...f, path: f.home && f.document ? relative(ws, join(f.home, f.document)) : f.document })) }
|
|
132
|
+
: { ran: false, note: r.error };
|
|
133
|
+
},
|
|
134
|
+
checkpoint: (dir, bin, ws) => {
|
|
135
|
+
const r = runJson(dir, bin, ['lint', join(ws, 'checkpoints')]);
|
|
136
|
+
return r.ok ? { ran: true, findings: findingsOf(r.json).map((f) => normalize(f, (s) => `checkpoints/${s}`)) } : { ran: false, note: r.error };
|
|
137
|
+
},
|
|
138
|
+
// One hdsop-md run checks the maps and the catalog; each finding says which kind it is about.
|
|
139
|
+
hdsop: (dir, bin, ws) => {
|
|
140
|
+
const r = runJson(dir, bin, ['lint', join(ws, 'workflows')]);
|
|
141
|
+
if (!r.ok) return { ran: false, note: r.error };
|
|
142
|
+
return { ran: true, findings: findingsOf(r.json).map((f) => ({ ...normalize(f, (s) => `workflows/${s}`), kind: f.kind === 'workflow-catalog' ? 'workflow-catalog' : 'hdsop' })) };
|
|
143
|
+
},
|
|
144
|
+
recurrent: (dir, bin, ws) => {
|
|
145
|
+
const r = runJson(dir, bin, ['lint', join(ws, 'recurrents')]);
|
|
146
|
+
return r.ok ? { ran: true, findings: findingsOf(r.json).map((f) => normalize({ ...f, path: f.recurrent ?? f.path }, (s) => `recurrents/${s}`)) } : { ran: false, note: r.error };
|
|
147
|
+
},
|
|
148
|
+
// tile-app-md finds the apps itself from the workspace, and names each by its absolute path.
|
|
149
|
+
'tile-app': (dir, bin, ws) => {
|
|
150
|
+
const r = runJson(dir, bin, ['lint', ws]);
|
|
151
|
+
return r.ok ? { ran: true, findings: findingsOf(r.json).map((f) => normalize(f, (s) => (s.startsWith('/') ? relative(ws, s) : s))) } : { ran: false, note: r.error };
|
|
152
|
+
},
|
|
153
|
+
// A conversation is checked in-process through the package's codec: one process per file
|
|
154
|
+
// would cost minutes on a workspace that holds thousands.
|
|
155
|
+
conversation: async (dir, _bin, ws, { records, read }) => {
|
|
156
|
+
let mod;
|
|
157
|
+
try { mod = await import(new URL(`file://${join(dir, 'index.mjs')}`)); } catch (e) { return { ran: false, note: e.message }; }
|
|
158
|
+
if (typeof mod.parseConversationHeader !== 'function' || typeof mod.validateConversation !== 'function') {
|
|
159
|
+
return { ran: false, note: 'this version exports no parseConversationHeader/validateConversation' };
|
|
160
|
+
}
|
|
161
|
+
const findings = [];
|
|
162
|
+
for (const rel of records) {
|
|
163
|
+
let parsed;
|
|
164
|
+
try { parsed = mod.parseConversationHeader(Buffer.from(read(join(ws, rel)))); }
|
|
165
|
+
catch (e) { findings.push({ level: 'error', code: e.code ?? 'header:unreadable', path: rel, message: e.message }); continue; }
|
|
166
|
+
const folderId = rel.split('/').at(-2);
|
|
167
|
+
const diagnostics = parsed.header ? mod.validateConversation(parsed.header, { folderId }) : parsed.diagnostics;
|
|
168
|
+
for (const d of diagnostics ?? []) {
|
|
169
|
+
const f = normalize({ level: d.severity ?? 'error', code: d.code, message: `${d.field ? `${d.field}: ` : ''}${d.message ?? ''}` });
|
|
170
|
+
findings.push({ ...f, path: rel });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return { ran: true, findings };
|
|
174
|
+
},
|
|
175
|
+
};
|
package/src/render.mjs
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
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
|
+
// What the kind's own package said: its count, and each record it would not touch and why.
|
|
45
|
+
for (const l of k.said ?? []) lines.push(` ${l}`);
|
|
46
|
+
for (const l of (k.skippedLines ?? []).slice(0, 10)) lines.push(` ${l}`);
|
|
47
|
+
if ((k.skippedLines?.length ?? 0) > 10) lines.push(` ... and ${k.skippedLines.length - 10} more skipped (--json lists every one)`);
|
|
48
|
+
}
|
|
49
|
+
if (lines.length === 1) lines.push(' nothing: every record already names its standard.');
|
|
50
|
+
return lines.join('\n');
|
|
51
|
+
}
|