@n8r/surface 0.0.0-stage → 0.1.0-experimental.5
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/LICENSE +202 -0
- package/LICENSE-DOCS +395 -0
- package/LICENSING.md +36 -0
- package/README.md +207 -2
- package/dist/activity.d.ts +16 -0
- package/dist/activity.js +44 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +412 -0
- package/dist/codec.d.ts +6 -0
- package/dist/codec.js +56 -0
- package/dist/codex.d.ts +72 -0
- package/dist/codex.js +526 -0
- package/dist/contracts.d.ts +235 -0
- package/dist/contracts.js +1 -0
- package/dist/cyclonedx-schemas/bom-1.7.schema.json +6700 -0
- package/dist/cyclonedx-schemas/cryptography-defs.schema.json +576 -0
- package/dist/cyclonedx-schemas/jsf-0.82.schema.json +240 -0
- package/dist/cyclonedx-schemas/spdx.schema.json +786 -0
- package/dist/cyclonedx-validation.d.ts +1 -0
- package/dist/cyclonedx-validation.js +45 -0
- package/dist/html.d.ts +5 -0
- package/dist/html.js +74 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +5 -0
- package/dist/notes.d.ts +52 -0
- package/dist/notes.js +184 -0
- package/dist/portable.d.ts +10 -0
- package/dist/portable.js +71 -0
- package/dist/recent.d.ts +9 -0
- package/dist/recent.js +71 -0
- package/dist/revisions.d.ts +2 -0
- package/dist/revisions.js +60 -0
- package/dist/settings.d.ts +23 -0
- package/dist/settings.js +100 -0
- package/dist/setup.d.ts +9 -0
- package/dist/setup.js +161 -0
- package/dist/skill-artifacts-cli.d.ts +1 -0
- package/dist/skill-artifacts-cli.js +13 -0
- package/dist/skill-artifacts.d.ts +15 -0
- package/dist/skill-artifacts.js +184 -0
- package/dist/skill-cyclonedx.d.ts +26 -0
- package/dist/skill-cyclonedx.js +214 -0
- package/dist/skill-presentation.d.ts +10 -0
- package/dist/skill-presentation.js +108 -0
- package/dist/skill-surface-cli.d.ts +1 -0
- package/dist/skill-surface-cli.js +37 -0
- package/dist/skill-surface-store.d.ts +7 -0
- package/dist/skill-surface-store.js +126 -0
- package/dist/source-tools.d.ts +102 -0
- package/dist/source-tools.js +201 -0
- package/dist/startup.d.ts +7 -0
- package/dist/startup.js +74 -0
- package/dist/surface-bom.d.ts +73 -0
- package/dist/surface-bom.js +139 -0
- package/dist/surface-draft.d.ts +22 -0
- package/dist/surface-draft.js +112 -0
- package/dist/surface-instructions.d.ts +3 -0
- package/dist/surface-instructions.js +32 -0
- package/dist/surface-overview.d.ts +15 -0
- package/dist/surface-overview.js +86 -0
- package/dist/surface-store.d.ts +34 -0
- package/dist/surface-store.js +213 -0
- package/dist/surface-view.d.ts +3 -0
- package/dist/surface-view.js +50 -0
- package/dist/validate.d.ts +4 -0
- package/dist/validate.js +482 -0
- package/docs/n8r-surface/README.md +133 -0
- package/docs/n8r-surface/cli-bundles.md +72 -0
- package/docs/n8r-surface/codex-research-transcript.md +51 -0
- package/docs/n8r-surface/codex-research-verification.md +176 -0
- package/docs/n8r-surface/command-outcomes.md +711 -0
- package/docs/n8r-surface/contracts.md +220 -0
- package/docs/n8r-surface/critical-path.md +184 -0
- package/docs/n8r-surface/design.md +289 -0
- package/docs/n8r-surface/first-use-access.md +143 -0
- package/docs/n8r-surface/first-use-overview.md +262 -0
- package/docs/n8r-surface/getting-started.md +74 -0
- package/docs/n8r-surface/guided-journey-verification.md +137 -0
- package/docs/n8r-surface/host-contract.md +176 -0
- package/docs/n8r-surface/implementation-decisions.md +96 -0
- package/docs/n8r-surface/instance-first-research.md +209 -0
- package/docs/n8r-surface/npm-distribution.md +107 -0
- package/docs/n8r-surface/npm-release.md +315 -0
- package/docs/n8r-surface/product-direction.md +327 -0
- package/docs/n8r-surface/recreation-handoff.md +117 -0
- package/docs/n8r-surface/researcher-discovery.md +157 -0
- package/docs/n8r-surface/researcher-prepared-check.md +117 -0
- package/docs/n8r-surface/researcher-reconciliation-trial.md +149 -0
- package/docs/n8r-surface/researcher-repository-check.md +95 -0
- package/docs/n8r-surface/researcher-skeleton-check.md +84 -0
- package/docs/n8r-surface/researcher-skill-verification.md +725 -0
- package/docs/n8r-surface/researcher-skill.md +309 -0
- package/docs/n8r-surface/researcher-vault.md +134 -0
- package/docs/n8r-surface/save-reopen-transcript.md +211 -0
- package/docs/n8r-surface/save-reopen-verification.md +152 -0
- package/docs/n8r-surface/security-assurance-strategy.md +267 -0
- package/docs/n8r-surface/skill-bom-verification.md +142 -0
- package/docs/n8r-surface/skill-cyclonedx-verification.md +144 -0
- package/docs/n8r-surface/skill-cyclonedx.md +85 -0
- package/docs/n8r-surface/skill-manual.md +129 -0
- package/docs/n8r-surface/skill-surface-artifacts.md +234 -0
- package/docs/n8r-surface/skill-usefulness-verification.md +135 -0
- package/docs/n8r-surface/skill-workspace-automation.md +359 -0
- package/docs/n8r-surface/startup-human-check.md +172 -0
- package/docs/n8r-surface/startup-verification.md +161 -0
- package/docs/n8r-surface/startup.md +151 -0
- package/docs/n8r-surface/surface-description-contract.md +224 -0
- package/docs/n8r-surface/surface-descriptions.md +173 -0
- package/docs/n8r-surface/surface-relationships.md +231 -0
- package/docs/n8r-surface/surface-revisions-transcript.md +552 -0
- package/docs/n8r-surface/surface-revisions-verification.md +161 -0
- package/docs/n8r-surface/tester-candidate.md +158 -0
- package/docs/n8r-surface/vault-setup-verification.md +210 -0
- package/docs/n8r-surface/verification.md +117 -0
- package/docs/n8r-surface/versioning.md +172 -0
- package/docs/n8r-surface/workspace-human-check.md +101 -0
- package/docs/n8r-surface/workspace-installation.md +68 -0
- package/docs/n8r-surface/workspace-verification.md +350 -0
- package/docs/n8r-surface/workspace.md +83 -0
- package/examples/researcher/README.md +100 -0
- package/examples/researcher/composed.surface.json +430 -0
- package/examples/researcher/composed.surface.yaml +283 -0
- package/examples/researcher/cyclonedx/configured/README.md +7 -0
- package/examples/researcher/cyclonedx/configured/manifest.json +10 -0
- package/examples/researcher/cyclonedx/configured/mapping.json +323 -0
- package/examples/researcher/cyclonedx/configured/surface-records.json +1275 -0
- package/examples/researcher/cyclonedx/configured/surface.cdx.json +79 -0
- package/examples/researcher/descriptions/README.md +29 -0
- package/examples/researcher/descriptions/bound-r1.json +141 -0
- package/examples/researcher/descriptions/bound-r2.json +153 -0
- package/examples/researcher/descriptions/capability.json +167 -0
- package/examples/researcher/descriptions/research-r1.json +153 -0
- package/examples/researcher/descriptions/research-r2.json +157 -0
- package/examples/researcher/descriptions/sources.md +24 -0
- package/examples/researcher/harbor-r1.research.html +193 -0
- package/examples/researcher/harbor-r2.research.html +226 -0
- package/examples/researcher/lumen-manual-edit.pending.html +271 -0
- package/examples/researcher/lumen-r1.research.html +271 -0
- package/examples/researcher/lumen-r2.research.html +280 -0
- package/examples/researcher/relationships/README.md +34 -0
- package/examples/researcher/relationships/bound-r1.json +346 -0
- package/examples/researcher/relationships/bound-r2.json +358 -0
- package/examples/researcher/relationships/capability.json +325 -0
- package/examples/researcher/relationships/config/assistant.env.example +4 -0
- package/examples/researcher/relationships/research-r1.json +300 -0
- package/examples/researcher/relationships/research-r2.json +304 -0
- package/examples/researcher/relationships/sources.md +38 -0
- package/examples/researcher/sources/harbor-api.md +16 -0
- package/examples/researcher/sources/harbor-auth.md +8 -0
- package/examples/researcher/sources/lumen-guide.md +28 -0
- package/examples/researcher/sources/lumen-settings.md +9 -0
- package/examples/researcher/surface-bom/README.md +17 -0
- package/examples/researcher/surface-bom/initial.cdx.json +188 -0
- package/package.json +45 -4
- package/plugins/n8r-surface/.codex-plugin/plugin.json +31 -0
- package/plugins/n8r-surface/LICENSE +202 -0
- package/plugins/n8r-surface/LICENSE-DOCS +395 -0
- package/plugins/n8r-surface/LICENSING.md +39 -0
- package/plugins/n8r-surface/assets/icon.svg +5 -0
- package/plugins/n8r-surface/plugin.json +27 -0
- package/plugins/n8r-surface/skills/research/SKILL.md +163 -0
- package/plugins/n8r-surface/skills/research/agents/openai.yaml +4 -0
- package/plugins/n8r-surface/skills/research/references/artifacts.md +152 -0
- package/plugins/n8r-surface/skills/research/references/cyclonedx.md +207 -0
- package/plugins/n8r-surface/skills/research/references/discovery-map.md +178 -0
- package/plugins/n8r-surface/skills/research/references/examples/bound-r1.json +346 -0
- package/plugins/n8r-surface/skills/research/references/examples/capability.json +325 -0
- package/plugins/n8r-surface/skills/research/references/examples/research-r1.json +300 -0
- package/plugins/n8r-surface/skills/research/references/examples/surface.cdx.json +188 -0
- package/plugins/n8r-surface/skills/research/references/first-use-access.md +97 -0
- package/plugins/n8r-surface/skills/research/references/first-use-overview.md +101 -0
- package/plugins/n8r-surface/skills/research/references/instance-first.md +125 -0
- package/plugins/n8r-surface/skills/research/references/model-meanings.md +55 -0
- package/plugins/n8r-surface/skills/research/references/skeleton-surface.md +105 -0
- package/plugins/n8r-surface/skills/research/references/working-notes.md +121 -0
- package/plugins/n8r-surface/skills/research/references/workspace.md +89 -0
- package/plugins/n8r-surface/skills/research/scripts/THIRD-PARTY-NOTICES.txt +273 -0
- package/plugins/n8r-surface/skills/research/scripts/artifacts.mjs +8348 -0
- package/plugins/n8r-surface/skills/research/scripts/cyclonedx.mjs +28113 -0
- package/plugins/n8r-surface/skills/research/scripts/surface.mjs +27800 -0
- package/plugins/n8r-surface/skills/research/scripts/workspace.mjs +120 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/** Bounded offline exchange alongside checked native records. No source inspection. */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { resolve, dirname, join } from 'node:path';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { checkBundle, readBundle, privacy, digest } from './skill-artifacts.js';
|
|
7
|
+
import { canonical } from './portable.js';
|
|
8
|
+
import { decodeJsonValue } from './codec.js';
|
|
9
|
+
import { verifySuccessor } from './revisions.js';
|
|
10
|
+
const members = ['surface.cdx.json', 'surface-records.json', 'mapping.json', 'README.md'];
|
|
11
|
+
const hash = (x) => `sha256:${createHash('sha256').update(x).digest('hex')}`;
|
|
12
|
+
const json = (x) => JSON.stringify(x, null, 2) + '\n';
|
|
13
|
+
function refuse() { throw Error('CycloneDX exchange refused: check contracts, mapping, privacy, lineage and saved bytes.'); }
|
|
14
|
+
function guard(fn) { try {
|
|
15
|
+
return fn();
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return refuse();
|
|
19
|
+
} }
|
|
20
|
+
export { validateBom } from './cyclonedx-validation.js';
|
|
21
|
+
import { validateBom } from './cyclonedx-validation.js';
|
|
22
|
+
function mappingFor(input, b) {
|
|
23
|
+
privacy(input);
|
|
24
|
+
if (!input || typeof input !== 'object' || Object.keys(input).sort().join(',') !== 'dependencies,nodes')
|
|
25
|
+
refuse();
|
|
26
|
+
const m = input;
|
|
27
|
+
if (!Array.isArray(m.nodes) || !Array.isArray(m.dependencies))
|
|
28
|
+
refuse();
|
|
29
|
+
const ids = new Set(b.surface.nodes.map(n => n.id));
|
|
30
|
+
if (m.nodes.length !== ids.size)
|
|
31
|
+
refuse();
|
|
32
|
+
for (const n of m.nodes) {
|
|
33
|
+
if (!n || !ids.delete(n.id) || !['component', 'service'].includes(n.kind) || typeof n.rationale !== 'string' || !n.rationale.trim())
|
|
34
|
+
refuse();
|
|
35
|
+
const keys = Object.keys(n).sort().join(',');
|
|
36
|
+
if (n.kind === 'component' ? (keys !== 'id,kind,rationale,type' || typeof n.type !== 'string') : keys !== 'id,kind,rationale')
|
|
37
|
+
refuse();
|
|
38
|
+
}
|
|
39
|
+
if (new Set(m.dependencies).size !== m.dependencies.length || m.dependencies.some(x => typeof x !== 'string'))
|
|
40
|
+
refuse();
|
|
41
|
+
return m;
|
|
42
|
+
}
|
|
43
|
+
function serial(id) {
|
|
44
|
+
const h = createHash('sha256').update('n8r.surface.cyclonedx/1\0' + id).digest('hex');
|
|
45
|
+
return `urn:uuid:${h.slice(0, 8)}-${h.slice(8, 12)}-5${h.slice(13, 16)}-a${h.slice(17, 20)}-${h.slice(20, 32)}`;
|
|
46
|
+
}
|
|
47
|
+
function checkPrevious(b, m, prev) {
|
|
48
|
+
const old = prev.bundle;
|
|
49
|
+
for (const key of ['research', 'surface']) {
|
|
50
|
+
if (digest(old[key]) !== digest(b[key])) {
|
|
51
|
+
verifySuccessor(old[key], b[key]);
|
|
52
|
+
if (b[key].revision.parent?.digest !== digest(old[key]))
|
|
53
|
+
refuse();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
for (const a of [old.research, old.surface, ...old.dependencies]) {
|
|
57
|
+
const next = [b.research, b.surface, ...b.dependencies].find(n => n.id === a.id && n.revision.id === a.revision.id);
|
|
58
|
+
if (next && digest(a) !== digest(next))
|
|
59
|
+
refuse();
|
|
60
|
+
}
|
|
61
|
+
for (const n of old.surface.nodes)
|
|
62
|
+
if (!b.surface.nodes.some(x => x.id === n.id))
|
|
63
|
+
refuse();
|
|
64
|
+
for (const d of old.research.decisions)
|
|
65
|
+
if (!b.research.decisions.some(x => canonical(x) === canonical(d)))
|
|
66
|
+
refuse();
|
|
67
|
+
for (const n of prev.mapping.nodes) {
|
|
68
|
+
const next = m.nodes.find(x => x.id === n.id);
|
|
69
|
+
if (!next || next.kind !== n.kind || next.type !== n.type)
|
|
70
|
+
refuse();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export function projectBundle(input, mapping, previous) {
|
|
74
|
+
return guard(() => {
|
|
75
|
+
const b = checkBundle(input), m = mappingFor(mapping, b), s = b.surface;
|
|
76
|
+
if (previous)
|
|
77
|
+
checkPrevious(b, m, previous);
|
|
78
|
+
const components = [], services = [];
|
|
79
|
+
for (const n of s.nodes) {
|
|
80
|
+
const t = m.nodes.find(x => x.id === n.id);
|
|
81
|
+
const item = { 'bom-ref': n.id, name: n.label, description: `${s.kind} Surface node; classification: ${t.rationale} This inventory does not establish deployment or runtime use.` };
|
|
82
|
+
if (t.kind === 'component') {
|
|
83
|
+
item.type = t.type;
|
|
84
|
+
components.push(item);
|
|
85
|
+
}
|
|
86
|
+
else
|
|
87
|
+
services.push(item);
|
|
88
|
+
}
|
|
89
|
+
const selected = new Set(m.dependencies), deps = new Map();
|
|
90
|
+
const relationships = s.relationships.map(e => {
|
|
91
|
+
const projected = selected.delete(e.id);
|
|
92
|
+
if (projected) {
|
|
93
|
+
if (!('binding' in e) || e.binding.state !== 'configured' || e.binding.claims.length !== 1)
|
|
94
|
+
refuse();
|
|
95
|
+
const targets = deps.get(e.from) ?? new Set();
|
|
96
|
+
targets.add(e.binding.claims[0].target);
|
|
97
|
+
deps.set(e.from, targets);
|
|
98
|
+
}
|
|
99
|
+
return { id: e.id, projected, reason: projected ? 'Explicit configured dependency projection; detailed semantics retained in native snapshot.' : 'Not projected as a dependency; see native relationship and evidence.', relationship: e };
|
|
100
|
+
});
|
|
101
|
+
if (selected.size)
|
|
102
|
+
refuse();
|
|
103
|
+
const bom = { bomFormat: 'CycloneDX', specVersion: '1.7', serialNumber: previous?.bom.serialNumber ?? serial(s.id), version: previous ? previous.bom.version + 1 : 1,
|
|
104
|
+
metadata: { timestamp: s.revision.createdAt }, ...(components.length ? { components } : {}), ...(services.length ? { services } : {}),
|
|
105
|
+
...(deps.size ? { dependencies: [...deps].map(([ref, targets]) => ({ ref, dependsOn: [...targets].sort() })) } : {}),
|
|
106
|
+
compositions: [{ aggregate: 'unknown' }], externalReferences: [{ type: 'other', url: 'surface-records.json', comment: 'Exact checked native research/Surface snapshot. Required for evidence and Surface meanings.' }, { type: 'other', url: 'mapping.json', comment: 'Mapping and explicit semantic losses; not a lossless BOM-only representation.' }] };
|
|
107
|
+
validateBom(bom);
|
|
108
|
+
const report = { profile: 'n8r.cyclonedx-exchange/1', standard: 'CycloneDX 1.7', surface: { id: s.id, revision: s.revision.id, digest: digest(s), kind: s.kind }, research: { id: b.research.id, revision: b.research.revision.id, digest: digest(b.research) }, mapping: m, relationships,
|
|
109
|
+
losses: ['Dependency projection does not encode purpose, integration mechanism, configuration evidence, binding states, alternatives or decisions; retained in surface-records.json and relationship report.', 'Node versions, package identifiers, upstream SBOM contents and transitive dependency completeness are not inferred from labels.', 'No runtime, control conformance, complete inventory or round-trip compatibility claim. Native records remain authoritative.'], gaps: s.gaps };
|
|
110
|
+
return { bom, report };
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
function safeDirectory(directory) {
|
|
114
|
+
for (let p = resolve(directory);; p = dirname(p)) {
|
|
115
|
+
try {
|
|
116
|
+
const stat = fs.lstatSync(p);
|
|
117
|
+
if (stat.isSymbolicLink() || !stat.isDirectory())
|
|
118
|
+
refuse();
|
|
119
|
+
}
|
|
120
|
+
catch (e) {
|
|
121
|
+
if (e.code !== 'ENOENT')
|
|
122
|
+
throw e;
|
|
123
|
+
}
|
|
124
|
+
if (p === dirname(p))
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function read(file) { const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); try {
|
|
129
|
+
const s = fs.fstatSync(fd);
|
|
130
|
+
if (!s.isFile() || s.size > 8 * 1024 * 1024)
|
|
131
|
+
refuse();
|
|
132
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(fs.readFileSync(fd));
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
fs.closeSync(fd);
|
|
136
|
+
} }
|
|
137
|
+
function readJson(file) { return decodeJsonValue(read(file)); }
|
|
138
|
+
function description(b, bom) { return `# CycloneDX exchange\n\nCycloneDX 1.7 · BOM version ${bom.version} · ${b.surface.kind} Surface.\n\nOpen surface.cdx.json in a compatible consumer. Keep surface-records.json and mapping.json with it: they preserve the native evidence and explain the projection/losses. Configured dependencies do not establish runtime use. Coverage remains unknown.\n\nUse the standalone helper read operation to verify this directory before continuation. Prior files must stay unchanged.\n`; }
|
|
139
|
+
export function readExchange(directory) {
|
|
140
|
+
return guard(() => {
|
|
141
|
+
directory = resolve(directory);
|
|
142
|
+
safeDirectory(directory);
|
|
143
|
+
if (fs.readdirSync(directory).sort().join(',') !== [...members, 'manifest.json'].sort().join(','))
|
|
144
|
+
refuse();
|
|
145
|
+
const manifest = readJson(join(directory, 'manifest.json'));
|
|
146
|
+
if (manifest.version !== 1 || Object.keys(manifest).sort().join(',') !== 'files,predecessor,version' || Object.keys(manifest.files ?? {}).sort().join(',') !== [...members].sort().join(','))
|
|
147
|
+
refuse();
|
|
148
|
+
if (manifest.predecessor !== null && !/^sha256:[a-f0-9]{64}$/.test(manifest.predecessor))
|
|
149
|
+
refuse();
|
|
150
|
+
for (const f of members)
|
|
151
|
+
if (hash(read(join(directory, f))) !== manifest.files[f])
|
|
152
|
+
refuse();
|
|
153
|
+
const bundle = checkBundle(readJson(join(directory, 'surface-records.json'))), report = readJson(join(directory, 'mapping.json')), bom = readJson(join(directory, 'surface.cdx.json'));
|
|
154
|
+
const mapping = mappingFor(report.mapping, bundle), expected = projectBundle(bundle, mapping);
|
|
155
|
+
validateBom(bom);
|
|
156
|
+
if (!Number.isSafeInteger(bom.version) || bom.version < 1 || (bom.version === 1) !== (manifest.predecessor === null))
|
|
157
|
+
refuse();
|
|
158
|
+
expected.bom.version = bom.version;
|
|
159
|
+
if (canonical(expected.bom) !== canonical(bom) || canonical(expected.report) !== canonical(report) || read(join(directory, 'README.md')) !== description(bundle, bom))
|
|
160
|
+
refuse();
|
|
161
|
+
return { bundle, mapping, bom, report, predecessor: manifest.predecessor };
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
export function saveExchange(bundleDirectory, mapping, destination, previousDirectory) {
|
|
165
|
+
return guard(() => {
|
|
166
|
+
const bundle = readBundle(bundleDirectory), m = mappingFor(mapping, bundle);
|
|
167
|
+
const previous = previousDirectory ? readExchange(previousDirectory) : undefined;
|
|
168
|
+
const prevBytes = previousDirectory ? read(join(previousDirectory, 'manifest.json')) : undefined;
|
|
169
|
+
if (!previous && (bundle.surface.revision.parent || bundle.research.revision.parent))
|
|
170
|
+
refuse();
|
|
171
|
+
const { bom, report } = projectBundle(bundle, m, previous);
|
|
172
|
+
const files = { 'surface.cdx.json': json(bom), 'surface-records.json': json(bundle), 'mapping.json': json(report), 'README.md': description(bundle, bom) };
|
|
173
|
+
const manifest = { version: 1, predecessor: prevBytes ? hash(prevBytes) : null, files: Object.fromEntries(members.map(n => [n, hash(files[n])])) };
|
|
174
|
+
destination = resolve(destination);
|
|
175
|
+
safeDirectory(dirname(destination));
|
|
176
|
+
fs.mkdirSync(destination, { mode: 0o700 });
|
|
177
|
+
for (const [name, bytes] of Object.entries({ ...files, 'manifest.json': json(manifest) }))
|
|
178
|
+
fs.writeFileSync(join(destination, name), bytes, { flag: 'wx', mode: 0o600 });
|
|
179
|
+
const saved = readExchange(destination);
|
|
180
|
+
if (previousDirectory) {
|
|
181
|
+
readExchange(previousDirectory);
|
|
182
|
+
if (read(join(previousDirectory, 'manifest.json')) !== prevBytes)
|
|
183
|
+
refuse();
|
|
184
|
+
}
|
|
185
|
+
readBundle(bundleDirectory);
|
|
186
|
+
return saved;
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
function main(args) {
|
|
190
|
+
if (args.length === 1 && args[0] === 'help') {
|
|
191
|
+
console.log('CycloneDX 1.7 exchange helper: export BUNDLE_DIRECTORY MAPPING.json NEW_DIRECTORY [PREVIOUS_EXCHANGE] | read DIRECTORY. Requires checked native records. Mapping: {nodes:[{id,kind,type?,rationale}],dependencies:[relationshipId]}. No network, source reads, runtime package install or product CLI.');
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (args[0] === 'read' && args.length === 2) {
|
|
195
|
+
const e = readExchange(args[1]);
|
|
196
|
+
console.log(json({ status: 'checked', bom: e.bom, report: e.report }));
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (args[0] === 'export' && (args.length === 4 || args.length === 5)) {
|
|
200
|
+
const e = saveExchange(args[1], readJson(args[2]), args[3], args[4]);
|
|
201
|
+
console.log(json({ status: 'saved-and-read-back', serialNumber: e.bom.serialNumber, version: e.bom.version }));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
refuse();
|
|
205
|
+
}
|
|
206
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
|
207
|
+
try {
|
|
208
|
+
main(process.argv.slice(2));
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
console.error('CycloneDX exchange failed. Check mapping, native records, privacy, predecessor and destination; no success claimed.');
|
|
212
|
+
process.exitCode = 1;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Versioned, deterministic human presentation. Never adds research claims. */
|
|
2
|
+
import type { Bundle } from './skill-artifacts.js';
|
|
3
|
+
interface Section {
|
|
4
|
+
title: string;
|
|
5
|
+
paragraphs: string[];
|
|
6
|
+
}
|
|
7
|
+
export declare function reportSections(b: Bundle): Section[];
|
|
8
|
+
export declare function renderBrief(b: Bundle): string;
|
|
9
|
+
export declare function renderBundleHtml(b: Bundle): string;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { canonical, checkSize, digest } from './portable.js';
|
|
2
|
+
const escape = (s) => s.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
3
|
+
const markdown = (s) => s.replace(/[\r\n]+/g, ' ').replace(/[\\`*_{}\[\]<>#!|]/g, '\\$&');
|
|
4
|
+
const embedded = (value) => JSON.stringify(value, null, 2).replace(/[<>&\u2028\u2029]/g, c => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
|
5
|
+
export function reportSections(b) {
|
|
6
|
+
const { research: r, surface: s } = b;
|
|
7
|
+
const records = [r, ...b.dependencies.filter((a) => a.contract === 'n8r.research/0.1')];
|
|
8
|
+
const name = (id) => s.nodes.find(n => n.id === id)?.label.replace(/ \(role endpoint\)$/, '') ?? (s.kind === 'bound' && s.instance.id === id ? s.instance.label : id);
|
|
9
|
+
const value = (v) => typeof v === 'string' ? name(v) : Array.isArray(v) ? v.map(value).join('; ') : v && typeof v === 'object' ? Object.entries(v).map(([k, x]) => `${k}: ${value(x)}`).join('; ') : String(v);
|
|
10
|
+
const citations = (basis) => basis.flatMap(ref => {
|
|
11
|
+
const record = records.find(x => x.id === ref.record && x.revision.id === ref.revision);
|
|
12
|
+
return (record?.findings.filter(f => ref.items.includes(f.id)) ?? []).flatMap(f => f.claims.flatMap(c => c.evidence.map(e => {
|
|
13
|
+
const source = record.sources.find(x => x.id === e.source);
|
|
14
|
+
return `${e.locator.startsWith(source?.location ?? '\0') ? e.locator : `${source?.location ?? e.source} — ${e.locator}`} (source revision ${source?.version ?? 'unspecified'}; research ${record.revision.id})`;
|
|
15
|
+
})));
|
|
16
|
+
});
|
|
17
|
+
const previous = r.revision.parent && records.find(x => x.id === r.revision.parent.id && x.revision.id === r.revision.parent.revision && digest(x) === r.revision.parent.digest);
|
|
18
|
+
const summary = [], changes = [], impact = [], evidence = [], unknown = [...s.gaps];
|
|
19
|
+
for (const edge of s.relationships) {
|
|
20
|
+
const connection = `${name(edge.from)} → ${name(edge.to)}`;
|
|
21
|
+
const prefix = s.relationships.length > 1 ? `${connection}: ` : '';
|
|
22
|
+
if ('binding' in edge) {
|
|
23
|
+
const targets = edge.binding.claims.map(c => name(c.target)).join(' / ');
|
|
24
|
+
summary.push(edge.binding.state === 'configured' ? `${name(edge.from)} is configured to use ${targets}.` : edge.binding.state === 'conflicting' ? `${connection}: the configured target is disputed (${targets}). No single target is established.` : `${connection}: target ${edge.binding.state}. ${edge.binding.note}`);
|
|
25
|
+
const prior = previous && previous.findings.find(f => s.kind === 'bound' && f.subject === s.instance.id && f.property === edge.id);
|
|
26
|
+
if (prior && prior.state === 'known' && edge.binding.state === 'configured') {
|
|
27
|
+
const old = prior.claims.map(c => value(c.value)).join(' / ');
|
|
28
|
+
const same = canonical(prior.claims.map(c => c.value).sort()) === canonical(edge.binding.claims.map(c => c.target).sort());
|
|
29
|
+
const oldDisplay = old === targets && !same ? prior.claims.map(c => `${value(c.value)} (${String(c.value)})`).join(' / ') : old;
|
|
30
|
+
const newDisplay = old === targets && !same ? edge.binding.claims.map(c => `${name(c.target)} (${c.target})`).join(' / ') : targets;
|
|
31
|
+
changes.push(same ? `${prefix}The recorded target remains ${targets}.` : `${prefix}${oldDisplay} was the previously recorded target; ${newDisplay} is the configured target in this revision.`);
|
|
32
|
+
}
|
|
33
|
+
evidence.push(...citations(edge.binding.claims.flatMap(c => c.basis)).map(c => `${prefix}Target selection: ${c}.`));
|
|
34
|
+
if (edge.binding.state !== 'configured')
|
|
35
|
+
unknown.push(`${connection}: binding ${edge.binding.state}. ${edge.binding.note}`);
|
|
36
|
+
if (edge.binding.state === 'configured')
|
|
37
|
+
impact.push(`${prefix}${targets} is the target to consider when reviewing this configured connection. Configuration alone does not establish reachability or actual use.`);
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
summary.push(edge.state === 'possible' ? `${name(edge.from)} can connect to ${name(edge.to)}, according to the documented capability. No instance target is established.` : `${connection}: capability ${edge.state}. ${edge.note}`);
|
|
41
|
+
impact.push(`${connection}: this describes a product possibility, not an instance selection or an exercised connection.`);
|
|
42
|
+
}
|
|
43
|
+
if (edge.conditions.length)
|
|
44
|
+
summary.push(`${connection} — Conditions: ${edge.conditions.join('; ')}`);
|
|
45
|
+
for (const [label, detail] of [['Purpose', edge.purpose], ['Integration', edge.integration]]) {
|
|
46
|
+
if (detail.claims.length)
|
|
47
|
+
impact.push(`${prefix}${label}${detail.state === 'known' ? '' : ` (${detail.state})`}: ${detail.claims.map(c => c.value).join(' / ')}`);
|
|
48
|
+
if (detail.state !== 'known')
|
|
49
|
+
unknown.push(`${connection}: ${label.toLowerCase()} ${detail.state}. ${detail.note}`);
|
|
50
|
+
evidence.push(...citations(detail.claims.flatMap(c => c.basis)).map(c => `${prefix}${label}: ${c}.`));
|
|
51
|
+
}
|
|
52
|
+
evidence.push(...citations(edge.basis).map(c => `${prefix}Documented support: ${c}.`));
|
|
53
|
+
evidence.push(...citations(edge.configuration.claims.flatMap(c => c.basis)).map(c => `${prefix}Configuration: ${c}.`));
|
|
54
|
+
if (edge.configuration.state !== 'known')
|
|
55
|
+
unknown.push(`${connection}: configuration ${edge.configuration.state}. ${edge.configuration.note}`);
|
|
56
|
+
}
|
|
57
|
+
if (s.kind === 'capability')
|
|
58
|
+
for (const group of s.exclusiveGroups)
|
|
59
|
+
summary.push(`Mutually exclusive alternatives: ${group.members.map(id => { const edge = s.relationships.find(e => e.id === id); return edge ? `${name(edge.from)} → ${name(edge.to)}` : id; }).join(' / ')}. These are not jointly available selections.`);
|
|
60
|
+
if (!changes.length)
|
|
61
|
+
changes.push(r.revision.parent ? 'A successor is recorded. An exact previous target comparison is unavailable in this bundle.' : 'This is the first saved description; no earlier revision is being compared.');
|
|
62
|
+
changes.push(`Reason recorded for this revision: ${r.revision.reason}.`);
|
|
63
|
+
const decisions = r.decisions.map(d => `${r.alternatives.find(a => a.id === d.alternative)?.title ?? d.alternative} — ${d.choice}. ${d.reason} Recorded by ${d.by} on ${d.at}.${previous?.decisions.some(x => canonical(x) === canonical(d)) ? ' Unchanged from the previous research revision.' : ''}`);
|
|
64
|
+
return [
|
|
65
|
+
{ title: 'Current picture', paragraphs: summary.length ? summary : ['No connections have been described yet. This does not establish their absence.'] },
|
|
66
|
+
{ title: 'What changed', paragraphs: changes },
|
|
67
|
+
{ title: 'Why it matters', paragraphs: impact.length ? impact : ['The recorded scope bounds what can be concluded. No operational impact has been established.'] },
|
|
68
|
+
{ title: 'Evidence', paragraphs: [...new Set(evidence)].length ? [...new Set(evidence)] : ['No connection evidence is recorded. Missing evidence does not establish absence.'] },
|
|
69
|
+
{ title: 'Decisions', paragraphs: decisions.length ? decisions : ['No human decisions are recorded.'] },
|
|
70
|
+
{ title: 'What remains unknown', paragraphs: [...new Set(unknown)].length ? [...new Set(unknown)] : ['No additional gaps are listed. This is not a completeness or runtime assurance claim.'] },
|
|
71
|
+
{ title: 'Questions to carry forward', paragraphs: r.questions.length ? r.questions.map(q => q.text) : ['No next investigation is recorded. Review the evidence and scope before extending the research.'] }
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
export function renderBrief(b) {
|
|
75
|
+
const sections = reportSections(b);
|
|
76
|
+
return `# ${markdown(b.surface.title)}\n\n${sections[0].paragraphs.map(markdown).join('\n\n')}\n\n` + sections.slice(1).map(s => `## ${s.title}\n\n${s.paragraphs.map(markdown).join('\n\n')}\n`).join('\n') + `\n## Scope\n\n${markdown(b.surface.scope)}\n` + '\n## Read further\n\n[Research and evidence](research.html) · [Checked Surface data](surface.json)\n';
|
|
77
|
+
}
|
|
78
|
+
export function renderBundleHtml(b) {
|
|
79
|
+
const sections = reportSections(b), lead = sections[0];
|
|
80
|
+
const section = (s, i) => `<section id="section-${i}"><h2>${s.title}</h2>${s.paragraphs.map(p => `<p>${escape(p)}</p>`).join('')}</section>`;
|
|
81
|
+
const records = [b.research, ...b.dependencies.filter((a) => a.contract === 'n8r.research/0.1')];
|
|
82
|
+
const html = `<!doctype html>
|
|
83
|
+
<html lang="en">
|
|
84
|
+
<head>
|
|
85
|
+
<meta charset="utf-8">
|
|
86
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
87
|
+
<meta name="n8r-presentation" content="1">
|
|
88
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'">
|
|
89
|
+
<title>${escape(b.surface.title)}</title>
|
|
90
|
+
<style>
|
|
91
|
+
:root{color-scheme:light;--ink:#172d46;--muted:#465c72;--blue:#2058a5;--wash:#edf4fc;--line:#c8d6e6;--paper:#fff}
|
|
92
|
+
*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:18px/1.65 'Avenir Next',Avenir,'Segoe UI',sans-serif}main{max-width:1120px;margin:auto;padding:48px 40px 72px}header{max-width:880px;margin-bottom:40px}h1{font-size:clamp(2rem,5vw,3.5rem);line-height:1.13;letter-spacing:-.035em;margin:16px 0 24px}h2{font-size:1.5rem;line-height:1.3;margin:0 0 20px}h3{font-size:1.1rem}p{max-width:72ch;margin:0 0 18px;overflow-wrap:anywhere}.meta,.scope,footer{color:var(--muted);font-size:.9rem}.lead{background:var(--wash);border-left:5px solid var(--blue);padding:24px 28px;margin:28px 0}.lead p{font-size:1.25rem;line-height:1.5}.lead p:last-child{margin-bottom:0}.layout{display:grid;grid-template-columns:190px minmax(0,1fr);gap:48px}nav{align-self:start;position:sticky;top:24px;font-size:.9rem}nav a{display:block;padding:7px 0}a{color:var(--blue);text-underline-offset:4px}a:focus-visible,summary:focus-visible{outline:3px solid var(--blue);outline-offset:4px}section{padding:0 0 28px;margin-bottom:32px;border-bottom:1px solid var(--line)}details{padding:20px 0;border-top:1px solid var(--line)}summary{cursor:pointer;font-weight:600}pre{white-space:pre-wrap;overflow-wrap:anywhere;font:13px/1.5 ui-monospace,monospace;background:#f3f6fa;padding:20px;max-height:36rem;overflow:auto}.source{margin:24px 0}footer{margin-top:40px} @media(max-width:720px){main{padding:28px 20px}.layout{display:block}nav{position:static;margin-bottom:32px;display:flex;flex-wrap:wrap;gap:4px 18px}.lead{padding:20px}h1{letter-spacing:-.02em}}@media print{main{padding:0}nav{display:none}.layout{display:block}details{break-inside:avoid}a{color:inherit}}
|
|
93
|
+
</style>
|
|
94
|
+
</head><body><main>
|
|
95
|
+
<header><p class="meta">${b.surface.kind === 'bound' ? 'Configured system' : 'Product capability'} · Revision ${escape(b.surface.revision.id)}</p><h1>${escape(b.surface.title)}</h1><div class="lead">${lead.paragraphs.map(p => `<p>${escape(p)}</p>`).join('')}</div><p class="scope">${escape(b.surface.scope)}</p></header>
|
|
96
|
+
<div class="layout"><nav aria-label="Report sections">${sections.slice(1).map((s, i) => `<a href="#section-${i + 1}">${s.title}</a>`).join('')}<a href="#sources">Source coverage</a></nav><article>
|
|
97
|
+
${sections.slice(1).map((s, i) => section(s, i + 1)).join('\n')}
|
|
98
|
+
<section id="sources"><h2>Source coverage</h2>${records.map(r => `<details><summary>${escape(r.title)} — ${escape(r.revision.id)}${r.id === b.research.id && r.revision.id === b.research.revision.id ? ' (current)' : ' (pinned evidence)'}</summary>${r.sources.map(s => `<div class="source"><h3>${escape(s.location)}</h3><p>Source revision ${escape(s.version)}. Inspected ${escape(s.inspectedAt)}.</p>${s.coverage.map(c => `<p>${escape(c.path)} — ${escape(c.status)}. ${escape(c.note)}</p>`).join('')}</div>`).join('') || '<p>No sources recorded.</p>'}</details>`).join('')}</section>
|
|
99
|
+
<details><summary>Technical record and exact evidence</summary><p>The complete checked data is retained below. IDs and hashes identify evidence; they do not establish runtime behavior.</p><pre>${escape(JSON.stringify(b, null, 2))}</pre></details>
|
|
100
|
+
<footer><p><a href="brief.md">Read the brief</a> · <a href="surface.json">Surface JSON</a> · <a href="dependencies.json">Pinned evidence JSON</a></p><p>Presentation 1. Visible edits require review; they are never silently incorporated into the checked data.</p></footer>
|
|
101
|
+
</article></div>
|
|
102
|
+
<script type="application/json" id="n8r-record">${embedded(b.research)}</script>
|
|
103
|
+
<script type="application/json" id="n8r-context">${embedded({ surface: b.surface, dependencies: b.dependencies })}</script>
|
|
104
|
+
</main></body></html>
|
|
105
|
+
`;
|
|
106
|
+
checkSize(html);
|
|
107
|
+
return html;
|
|
108
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function main(args: string[]): void;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Standalone installed-skill entry point; separate from the product CLI. */
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { checkSurfaceBom } from './surface-bom.js';
|
|
5
|
+
import { readJson, readSurface, saveSurface, byteDigest, readBytes } from './skill-surface-store.js';
|
|
6
|
+
export function main(args) {
|
|
7
|
+
const [op, file, dest, previous] = args;
|
|
8
|
+
if (op === 'help' && args.length === 1) {
|
|
9
|
+
console.log('Surface helper: check BOM.json | read BOM.json_OR_DIRECTORY | save BOM.json NEW_DIRECTORY [PREVIOUS_BOM_OR_DIRECTORY] | reconcile BOM.json NEW_DIRECTORY PREVIOUS_DIRECTORY. CycloneDX 1.7 Surface profile 1. New saves have one BOM authority and generated brief, journal and HTML map. Reconcile only reviewed view edits; it requires a reconciliation explanation in the BOM. Offline; Node 22/24/26; no product CLI or runtime install.');
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
if (op === 'check' && args.length === 2) {
|
|
13
|
+
const s = checkSurfaceBom(readJson(file));
|
|
14
|
+
console.log(JSON.stringify({ status: 'checked', relationships: s.relationships.length, entries: s.entries.length }));
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (op === 'read' && args.length === 2) {
|
|
18
|
+
const s = readSurface(file);
|
|
19
|
+
console.log(JSON.stringify({ status: 'checked', ...s }, null, 2));
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if ((op === 'save' && (args.length === 3 || args.length === 4)) || (op === 'reconcile' && args.length === 4)) {
|
|
23
|
+
const s = saveSurface(readJson(file), dest, previous, op === 'reconcile');
|
|
24
|
+
console.log(JSON.stringify({ status: 'saved-and-read-back', serialNumber: s.bom.serialNumber, version: s.bom.version, bomDigest: byteDigest(readBytes(resolve(dest, 'surface.cdx.json'))), files: ['surface.html', 'brief.md', 'investigation.md', 'surface.cdx.json'] }));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
throw Error('Arguments refused; run help.');
|
|
28
|
+
}
|
|
29
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
|
30
|
+
try {
|
|
31
|
+
main(process.argv.slice(2));
|
|
32
|
+
}
|
|
33
|
+
catch (e) {
|
|
34
|
+
console.error(e instanceof Error ? e.message : 'Surface operation failed; no success claimed.');
|
|
35
|
+
process.exitCode = 1;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type Surface } from './surface-bom.js';
|
|
2
|
+
export declare const byteDigest: (bytes: string | Buffer) => string;
|
|
3
|
+
export declare function safeParents(directory: string): void;
|
|
4
|
+
export declare function readBytes(file: string): NonSharedBuffer;
|
|
5
|
+
export declare const readJson: (file: string) => unknown;
|
|
6
|
+
export declare function readSurface(file: string): Surface;
|
|
7
|
+
export declare function saveSurface(input: unknown, destination: string, previous?: string, reconcile?: boolean): Surface;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/** Immutable BOM revisions, with disposable views and explicit edit reconciliation. */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { resolve, dirname, join, basename } from 'node:path';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { canonical } from './portable.js';
|
|
6
|
+
import { decodeJsonValue } from './codec.js';
|
|
7
|
+
import { checkSurfaceBom, prefix } from './surface-bom.js';
|
|
8
|
+
import { renderSurface } from './surface-view.js';
|
|
9
|
+
export const byteDigest = (bytes) => 'sha256:' + createHash('sha256').update(bytes).digest('hex');
|
|
10
|
+
const refuse = (reason) => { throw Error(`Surface save/read refused: ${reason}. No input values shown.`); };
|
|
11
|
+
export function safeParents(directory) {
|
|
12
|
+
for (let p = resolve(directory);; p = dirname(p)) {
|
|
13
|
+
try {
|
|
14
|
+
const s = fs.lstatSync(p);
|
|
15
|
+
if (!s.isDirectory() || s.isSymbolicLink())
|
|
16
|
+
refuse('unsafe directory');
|
|
17
|
+
}
|
|
18
|
+
catch (e) {
|
|
19
|
+
if (e.code !== 'ENOENT')
|
|
20
|
+
throw e;
|
|
21
|
+
}
|
|
22
|
+
if (p === dirname(p))
|
|
23
|
+
break;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function readBytes(file) { safeParents(dirname(file)); const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); try {
|
|
27
|
+
const s = fs.fstatSync(fd);
|
|
28
|
+
if (!s.isFile() || s.size > 8 * 1024 * 1024)
|
|
29
|
+
refuse('file type or size');
|
|
30
|
+
return fs.readFileSync(fd);
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
fs.closeSync(fd);
|
|
34
|
+
} }
|
|
35
|
+
export const readJson = (file) => decodeJsonValue(new TextDecoder('utf8', { fatal: true }).decode(readBytes(file)));
|
|
36
|
+
const prop = (s, key) => s.bom.properties.find((p) => p.name === prefix + key)?.value;
|
|
37
|
+
function location(file) { safeParents(dirname(file)); return fs.lstatSync(file).isDirectory() ? join(file, 'surface.cdx.json') : file; }
|
|
38
|
+
function savedDirectory(file) {
|
|
39
|
+
const p = resolve(file);
|
|
40
|
+
if (fs.lstatSync(p).isDirectory())
|
|
41
|
+
return p;
|
|
42
|
+
if (basename(p) === 'surface.cdx.json' && fs.existsSync(join(dirname(p), 'manifest.json')))
|
|
43
|
+
return dirname(p);
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
export function readSurface(file) { const directory = savedDirectory(file); if (directory)
|
|
47
|
+
checkViews(directory, false); return checkSurfaceBom(readJson(location(resolve(file)))); }
|
|
48
|
+
function checkViews(directory, reconcile) {
|
|
49
|
+
const manifest = readJson(join(directory, 'manifest.json'));
|
|
50
|
+
if (manifest.profile !== 'n8r.surface-save/1' || Object.keys(manifest.files ?? {}).sort().join(',') !== 'brief.md,investigation.md,surface.cdx.json,surface.html')
|
|
51
|
+
refuse('manifest');
|
|
52
|
+
for (const [name, hash] of Object.entries(manifest.files)) {
|
|
53
|
+
const changed = byteDigest(readBytes(join(directory, name))) !== hash;
|
|
54
|
+
if (changed && (name === 'surface.cdx.json' || !reconcile))
|
|
55
|
+
refuse(name === 'surface.cdx.json' ? 'changed predecessor BOM' : 'edited generated view; reconcile proposals into a successor explicitly');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export function saveSurface(input, destination, previous, reconcile = false) {
|
|
59
|
+
const s = checkSurfaceBom(input);
|
|
60
|
+
destination = resolve(destination);
|
|
61
|
+
safeParents(dirname(destination));
|
|
62
|
+
let prevFile, previousBytes;
|
|
63
|
+
if (previous) {
|
|
64
|
+
const prior = resolve(previous);
|
|
65
|
+
prevFile = location(prior);
|
|
66
|
+
previousBytes = readBytes(prevFile);
|
|
67
|
+
const old = checkSurfaceBom(decodeJsonValue(previousBytes.toString('utf8')));
|
|
68
|
+
const saved = savedDirectory(prior);
|
|
69
|
+
if (saved)
|
|
70
|
+
checkViews(saved, reconcile);
|
|
71
|
+
if (s.bom.serialNumber !== old.bom.serialNumber || s.bom.version !== old.bom.version + 1 || prop(s, 'predecessor-digest') !== byteDigest(previousBytes))
|
|
72
|
+
refuse('revision identity/digest');
|
|
73
|
+
const link = `urn:cdx:${old.bom.serialNumber.slice(9)}/${old.bom.version}`;
|
|
74
|
+
if (!s.bom.externalReferences?.some((r) => r.type === 'bom' && r.url === link))
|
|
75
|
+
refuse('versioned predecessor BOM-Link');
|
|
76
|
+
for (const n of [...(old.bom.components ?? []), ...(old.bom.services ?? [])])
|
|
77
|
+
if (![...(s.bom.components ?? []), ...(s.bom.services ?? [])].some(x => x['bom-ref'] === n['bom-ref']))
|
|
78
|
+
refuse('lost node identity');
|
|
79
|
+
for (const e of old.relationships) {
|
|
80
|
+
const n = s.relationships.find(x => x.id === e.id);
|
|
81
|
+
if (!n || n.from !== e.from || n.to !== e.to || n.kind !== e.kind)
|
|
82
|
+
refuse('lost or changed relationship identity');
|
|
83
|
+
for (const o of e.observations)
|
|
84
|
+
if (!n.observations.some(x => canonical(x) === canonical(o)))
|
|
85
|
+
refuse('lost historical observation');
|
|
86
|
+
}
|
|
87
|
+
for (const e of old.entries)
|
|
88
|
+
if (!s.entries.some(x => x.id === e.id))
|
|
89
|
+
refuse('lost investigation entry');
|
|
90
|
+
for (const a of old.bom.annotations ?? [])
|
|
91
|
+
if (!s.bom.annotations?.some((n) => canonical(n) === canonical(a)))
|
|
92
|
+
refuse('rewritten historical evidence');
|
|
93
|
+
for (const [i, r] of (old.bom.externalReferences ?? []).entries())
|
|
94
|
+
if (canonical(s.bom.externalReferences?.[i]) !== canonical(r))
|
|
95
|
+
refuse('changed source reference');
|
|
96
|
+
for (const e of old.evidence)
|
|
97
|
+
if (!s.evidence.some(n => canonical(n) === canonical(e)))
|
|
98
|
+
refuse('changed evidence applicability');
|
|
99
|
+
for (const e of old.entries.filter(e => e.kind === 'decision')) {
|
|
100
|
+
const next = s.entries.find(x => x.id === e.id);
|
|
101
|
+
if (!next || canonical({ ...next, status: e.status }) !== canonical(e))
|
|
102
|
+
refuse('lost or rewritten decision');
|
|
103
|
+
if (next.status !== e.status && (next.status !== 'superseded' || !s.entries.some(x => x.supersedes === e.id && x.kind === 'decision')))
|
|
104
|
+
refuse('decision supersession');
|
|
105
|
+
for (const id of e.evidence) {
|
|
106
|
+
const a = old.bom.annotations.find((a) => a['bom-ref'] === id);
|
|
107
|
+
if (!s.bom.annotations.some((b) => canonical(a) === canonical(b)))
|
|
108
|
+
refuse('rewritten decision provenance');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
else if (s.bom.version !== 1 || prop(s, 'predecessor-digest') !== undefined)
|
|
113
|
+
refuse('predecessor required');
|
|
114
|
+
if (reconcile && (!previous || !prop(s, 'reconciliation')))
|
|
115
|
+
refuse('reconciliation explanation required');
|
|
116
|
+
const files = { 'surface.cdx.json': JSON.stringify(s.bom, null, 2) + '\n', ...renderSurface(s) };
|
|
117
|
+
const manifest = JSON.stringify({ profile: 'n8r.surface-save/1', files: Object.fromEntries(Object.entries(files).map(([n, v]) => [n, byteDigest(v)])) }, null, 2) + '\n';
|
|
118
|
+
fs.mkdirSync(destination, { mode: 0o700 });
|
|
119
|
+
for (const [name, bytes] of Object.entries({ ...files, 'manifest.json': manifest }))
|
|
120
|
+
fs.writeFileSync(join(destination, name), bytes, { flag: 'wx', mode: 0o600 });
|
|
121
|
+
checkViews(destination, false);
|
|
122
|
+
const saved = readSurface(destination);
|
|
123
|
+
if (prevFile && !readBytes(prevFile).equals(previousBytes))
|
|
124
|
+
refuse('predecessor changed during save');
|
|
125
|
+
return saved;
|
|
126
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
type Entry = {
|
|
2
|
+
path: string;
|
|
3
|
+
kind: 'file' | 'directory';
|
|
4
|
+
};
|
|
5
|
+
type Omission = {
|
|
6
|
+
path: string;
|
|
7
|
+
reason: string;
|
|
8
|
+
};
|
|
9
|
+
/** Local read capability. Directory topology must remain stable during an operation. */
|
|
10
|
+
export declare class SourceTools {
|
|
11
|
+
readonly root: string;
|
|
12
|
+
readonly file: string | undefined;
|
|
13
|
+
private identity;
|
|
14
|
+
constructor(source: string);
|
|
15
|
+
get selected(): string;
|
|
16
|
+
private checkRoot;
|
|
17
|
+
private path;
|
|
18
|
+
list(path?: string): {
|
|
19
|
+
entries: Entry[];
|
|
20
|
+
excluded: Omission[];
|
|
21
|
+
truncated: boolean;
|
|
22
|
+
};
|
|
23
|
+
private bytes;
|
|
24
|
+
read(path: string, startLine?: number, lineCount?: number): {
|
|
25
|
+
path: string;
|
|
26
|
+
digest: string;
|
|
27
|
+
inspectedAt: string;
|
|
28
|
+
startLine: number;
|
|
29
|
+
endLine: number;
|
|
30
|
+
totalLines: number;
|
|
31
|
+
text: string;
|
|
32
|
+
truncated: boolean;
|
|
33
|
+
};
|
|
34
|
+
search(query: string): {
|
|
35
|
+
query: string;
|
|
36
|
+
matches: {
|
|
37
|
+
path: string;
|
|
38
|
+
line: number;
|
|
39
|
+
text: string;
|
|
40
|
+
digest: string;
|
|
41
|
+
}[];
|
|
42
|
+
excluded: Omission[];
|
|
43
|
+
truncated: boolean;
|
|
44
|
+
filesInspected: number;
|
|
45
|
+
inspectedAt: string;
|
|
46
|
+
};
|
|
47
|
+
call(tool: string, args: unknown): unknown;
|
|
48
|
+
}
|
|
49
|
+
export declare const sourceToolSpecs: ({
|
|
50
|
+
name: string;
|
|
51
|
+
description: string;
|
|
52
|
+
inputSchema: {
|
|
53
|
+
type: string;
|
|
54
|
+
properties: {
|
|
55
|
+
path: {
|
|
56
|
+
type: string;
|
|
57
|
+
};
|
|
58
|
+
startLine?: undefined;
|
|
59
|
+
lineCount?: undefined;
|
|
60
|
+
query?: undefined;
|
|
61
|
+
};
|
|
62
|
+
required: string[];
|
|
63
|
+
additionalProperties: boolean;
|
|
64
|
+
};
|
|
65
|
+
} | {
|
|
66
|
+
name: string;
|
|
67
|
+
description: string;
|
|
68
|
+
inputSchema: {
|
|
69
|
+
type: string;
|
|
70
|
+
properties: {
|
|
71
|
+
path: {
|
|
72
|
+
type: string;
|
|
73
|
+
};
|
|
74
|
+
startLine: {
|
|
75
|
+
type: string;
|
|
76
|
+
};
|
|
77
|
+
lineCount: {
|
|
78
|
+
type: string;
|
|
79
|
+
};
|
|
80
|
+
query?: undefined;
|
|
81
|
+
};
|
|
82
|
+
required: string[];
|
|
83
|
+
additionalProperties: boolean;
|
|
84
|
+
};
|
|
85
|
+
} | {
|
|
86
|
+
name: string;
|
|
87
|
+
description: string;
|
|
88
|
+
inputSchema: {
|
|
89
|
+
type: string;
|
|
90
|
+
properties: {
|
|
91
|
+
query: {
|
|
92
|
+
type: string;
|
|
93
|
+
};
|
|
94
|
+
path?: undefined;
|
|
95
|
+
startLine?: undefined;
|
|
96
|
+
lineCount?: undefined;
|
|
97
|
+
};
|
|
98
|
+
required: string[];
|
|
99
|
+
additionalProperties: boolean;
|
|
100
|
+
};
|
|
101
|
+
})[];
|
|
102
|
+
export {};
|