@canonical/pragma-cli 0.32.0 → 0.34.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/README.md +72 -0
- package/dist/pragma +0 -0
- package/package.json +19 -15
- package/pragma.conf.ts +664 -0
- package/src/bin.ts +15 -6
- package/src/capabilities/block.parity.test.ts +250 -0
- package/src/capabilities/capabilities/capabilities.render.ts +3 -2
- package/src/capabilities/capabilities/capabilities.test.ts +10 -1
- package/src/capabilities/capabilities/capabilities.verb.ts +32 -14
- package/src/capabilities/capabilities/catalog.ts +20 -8
- package/src/capabilities/capabilities/hints.ts +21 -36
- package/src/capabilities/capabilities/types.ts +2 -0
- package/src/capabilities/colophon/collectColophon.ts +34 -19
- package/src/capabilities/colophon/colophon.render.test.ts +31 -4
- package/src/capabilities/colophon/colophon.render.ts +13 -4
- package/src/capabilities/colophon/colophon.test.ts +3 -3
- package/src/capabilities/colophon/colophon.verb.ts +11 -4
- package/src/capabilities/colophon/markdownTerminal.ts +33 -14
- package/src/capabilities/colophon/types.ts +16 -7
- package/src/capabilities/config/collectConfigShow.test.ts +57 -0
- package/src/capabilities/config/collectConfigShow.ts +41 -4
- package/src/capabilities/config/fields.ts +10 -108
- package/src/capabilities/config/set.test.ts +6 -6
- package/src/capabilities/config/set.verb.ts +12 -8
- package/src/capabilities/config/show.render.test.ts +137 -0
- package/src/capabilities/config/show.render.ts +57 -16
- package/src/capabilities/config/show.verb.ts +9 -8
- package/src/capabilities/config/types.ts +8 -0
- package/src/capabilities/create/byteEquality.subprocess.test.ts +1 -1
- package/src/capabilities/create/byteEquality.test.ts +82 -101
- package/src/capabilities/create/compiledCreate.subprocess.test.ts +53 -16
- package/src/capabilities/create/constants.ts +75 -0
- package/src/capabilities/create/create.mcp.test.ts +2 -2
- package/src/capabilities/create/create.test.ts +192 -16
- package/src/capabilities/create/create.verb.ts +208 -79
- package/src/capabilities/create/index.ts +2 -10
- package/src/capabilities/create/pickGenerator.ts +34 -28
- package/src/capabilities/create/templates.embedded.generated.ts +4 -29
- package/src/capabilities/create/types.ts +10 -6
- package/src/capabilities/{standard/parity.test.ts → distribution.parity.test.ts} +24 -14
- package/src/capabilities/distribution.test.ts +120 -0
- package/src/capabilities/distribution.ts +59 -0
- package/src/capabilities/doctor/checks/checkConfigFile.ts +6 -5
- package/src/capabilities/doctor/checks/checkKeStore.ts +2 -2
- package/src/capabilities/doctor/checks/checkMcpCommands.ts +6 -0
- package/src/capabilities/doctor/checks/checkMcpConfigured.ts +15 -8
- package/src/capabilities/doctor/checks/checkPackageRefs.ts +79 -45
- package/src/capabilities/doctor/checks/checkPragmaVersion.ts +3 -2
- package/src/capabilities/doctor/checks/checkShellCompletions.test.ts +61 -11
- package/src/capabilities/doctor/checks/checkShellCompletions.ts +63 -23
- package/src/capabilities/doctor/checks/checkSkillsSymlinked.ts +3 -2
- package/src/capabilities/doctor/checks/deriveBand.test.ts +42 -0
- package/src/capabilities/doctor/checks/deriveBand.ts +28 -0
- package/src/capabilities/doctor/doctor.render.test.ts +170 -0
- package/src/capabilities/doctor/doctor.render.ts +117 -34
- package/src/capabilities/doctor/doctor.test.ts +68 -9
- package/src/capabilities/doctor/doctor.verb.ts +6 -2
- package/src/capabilities/doctor/runChecks.ts +33 -9
- package/src/capabilities/doctor/types.ts +16 -1
- package/src/capabilities/emptyRecovery.test.ts +32 -15
- package/src/capabilities/graph/inspect.verb.ts +3 -2
- package/src/capabilities/graph/query.verb.ts +3 -2
- package/src/capabilities/graph/runQuery.ts +8 -5
- package/src/capabilities/index.ts +22 -18
- package/src/capabilities/info/collectInfo.ts +10 -6
- package/src/capabilities/info/info.render.ts +3 -2
- package/src/capabilities/info/info.verb.ts +3 -2
- package/src/capabilities/lazy.test.ts +93 -2
- package/src/capabilities/ontology/index.ts +10 -3
- package/src/capabilities/ontology/ontology.test.ts +157 -5
- package/src/capabilities/ontology/verbs.ts +203 -104
- package/src/capabilities/prompt/prompt.render.test.ts +94 -0
- package/src/capabilities/prompt/prompt.render.ts +23 -6
- package/src/capabilities/prompt/prompt.test.ts +68 -1
- package/src/capabilities/prompt/verbs.ts +24 -11
- package/src/capabilities/reference.test.ts +32 -0
- package/src/capabilities/resources/provider.ts +58 -25
- package/src/capabilities/resources/resources.test.ts +176 -21
- package/src/capabilities/scopeTypeSync.test.ts +37 -0
- package/src/capabilities/setup/index.ts +7 -1
- package/src/capabilities/setup/operations/setupCompletions.ts +49 -16
- package/src/capabilities/setup/operations/setupGenerator.ts +166 -63
- package/src/capabilities/setup/operations/setupLsp.ts +56 -12
- package/src/capabilities/setup/operations/setupMcp.ts +216 -39
- package/src/capabilities/setup/operations/setupSkills.ts +10 -4
- package/src/capabilities/setup/setup.render.test.ts +109 -0
- package/src/capabilities/setup/setup.render.ts +61 -14
- package/src/capabilities/setup/setup.test.ts +352 -1
- package/src/capabilities/setup/setup.verb.ts +68 -9
- package/src/capabilities/setup/shell.ts +29 -3
- package/src/capabilities/setup/types.ts +59 -2
- package/src/capabilities/shared/bands.ts +16 -0
- package/src/capabilities/skill/discover.ts +11 -5
- package/src/capabilities/skill/render.ts +4 -5
- package/src/capabilities/skill/verbs.ts +7 -7
- package/src/capabilities/sources/collectStatus.ts +69 -49
- package/src/capabilities/sources/index.ts +1 -1
- package/src/capabilities/sources/installSkills.ts +2 -2
- package/src/capabilities/sources/runUpdate.ts +146 -98
- package/src/capabilities/sources/sources.test.ts +264 -84
- package/src/capabilities/sources/status.render.test.ts +94 -0
- package/src/capabilities/sources/status.render.ts +55 -33
- package/src/capabilities/sources/status.verb.ts +15 -8
- package/src/capabilities/sources/types.ts +18 -25
- package/src/capabilities/sources/update.render.ts +9 -2
- package/src/capabilities/sources/update.verb.ts +12 -23
- package/src/capabilities/surface.test.ts +65 -17
- package/src/capabilities/tier.parity.test.ts +144 -0
- package/src/capabilities/upgrade/upgrade.verb.ts +7 -3
- package/src/constants.test.ts +9 -7
- package/src/constants.ts +49 -23
- package/src/identity.test.ts +333 -0
- package/src/index.ts +2 -2
- package/src/kernel/completion/__snapshots__/emitScripts.test.ts.snap +18 -16
- package/src/kernel/completion/complete.test.ts +3 -5
- package/src/kernel/completion/emitScripts.test.ts +1 -1
- package/src/kernel/completion/emitScripts.ts +4 -2
- package/src/kernel/completion/entitySource.test.ts +185 -41
- package/src/kernel/completion/entitySource.ts +131 -113
- package/src/kernel/completion/model.test.ts +3 -3
- package/src/kernel/completion/model.ts +11 -1
- package/src/kernel/completion/parse.test.ts +2 -4
- package/src/kernel/completion/safety.test.ts +82 -36
- package/src/kernel/completion/shellDrive.test.ts +592 -0
- package/src/kernel/completion/templates/fish.ts +28 -6
- package/src/kernel/completion/types.ts +3 -3
- package/src/kernel/config/__snapshots__/firstRun.test.ts.snap +1 -1
- package/src/kernel/config/defaults.test.ts +85 -0
- package/src/kernel/config/defaults.ts +12 -23
- package/src/kernel/config/evaluateProjectConfig.ts +2 -3
- package/src/kernel/config/findProjectConfig.ts +4 -3
- package/src/kernel/config/firstRun.ts +12 -5
- package/src/kernel/config/globalConfig.ts +2 -2
- package/src/kernel/config/index.ts +4 -5
- package/src/kernel/config/paths.ts +17 -17
- package/src/kernel/config/readConfig.test.ts +242 -6
- package/src/kernel/config/readConfig.ts +8 -8
- package/src/kernel/config/schema.test.ts +50 -0
- package/src/kernel/config/schema.ts +97 -10
- package/src/kernel/config/types.ts +73 -19
- package/src/kernel/copy.test.ts +453 -0
- package/src/kernel/error/PragmaError.test.ts +5 -5
- package/src/kernel/error/constants.ts +3 -2
- package/src/kernel/error/index.ts +1 -1
- package/src/kernel/error/recovery.test.ts +22 -45
- package/src/kernel/error/recovery.ts +22 -27
- package/src/kernel/error/renderError.ts +1 -1
- package/src/kernel/error/types.ts +5 -4
- package/src/kernel/packs/collect.test.ts +195 -11
- package/src/kernel/packs/collect.ts +213 -58
- package/src/kernel/packs/compile.test.ts +163 -7
- package/src/kernel/packs/compile.ts +15 -12
- package/src/kernel/packs/disclosure.test.ts +5 -2
- package/src/kernel/packs/graphql/fetchGraphqlLookup.ts +11 -10
- package/src/kernel/packs/graphqlSource.test.ts +6 -3
- package/src/kernel/packs/iri.ts +5 -7
- package/src/kernel/packs/renderPack.ts +5 -3
- package/src/kernel/packs/resolveEntity.ts +21 -17
- package/src/kernel/packs/runBodies.ts +15 -14
- package/src/kernel/packs/schema.ts +96 -10
- package/src/kernel/packs/sparql/runSelect.test.ts +104 -10
- package/src/kernel/packs/sparql/runSelect.ts +73 -34
- package/src/kernel/packs/toolDescription.test.ts +10 -2
- package/src/kernel/packs/types.ts +39 -4
- package/src/kernel/packs/uniqueness.ts +4 -4
- package/src/kernel/project/cli/buildProgram.test.ts +60 -11
- package/src/kernel/project/cli/buildProgram.ts +11 -0
- package/src/kernel/project/cli/dispatch.test.ts +67 -0
- package/src/kernel/project/cli/dispatch.ts +34 -12
- package/src/kernel/project/cli/globalFlags.test.ts +12 -4
- package/src/kernel/project/cli/globalFlags.ts +14 -14
- package/src/kernel/project/cli/helpFormat.ts +1 -1
- package/src/kernel/project/cli/rootHelp.ts +109 -107
- package/src/kernel/project/mcp/buildServer.ts +11 -6
- package/src/kernel/project/mcp/instructions.ts +12 -7
- package/src/kernel/project/mcp/mcpError.ts +52 -0
- package/src/kernel/project/mcp/parity.test.ts +39 -1
- package/src/kernel/project/mcp/prompts/prompts.test.ts +128 -5
- package/src/kernel/project/mcp/prompts/provider.ts +51 -7
- package/src/kernel/project/mcp/prompts/source.ts +68 -67
- package/src/kernel/project/mcp/registerVerb.ts +25 -6
- package/src/kernel/project/mcp/serve.ts +11 -3
- package/src/kernel/project/mcp/toolDescriptions.test.ts +6 -2
- package/src/kernel/render/disclosure.ts +4 -2
- package/src/kernel/render/formatters.ts +5 -4
- package/src/kernel/render/prefixes.test.ts +36 -0
- package/src/kernel/render/prefixes.ts +40 -13
- package/src/kernel/render/renderers.test.ts +35 -1
- package/src/kernel/render/renderers.ts +25 -7
- package/src/kernel/render/style.test.ts +31 -0
- package/src/kernel/render/style.ts +74 -0
- package/src/kernel/runtime/graphpack/build.ts +57 -3
- package/src/kernel/runtime/graphpack/buildIndex.test.ts +149 -0
- package/src/kernel/runtime/graphpack/buildIndex.ts +173 -50
- package/src/kernel/runtime/graphpack/embedded/pack.generated.ts +5 -8
- package/src/kernel/runtime/graphpack/embedded/pack.index.generated.ts +3 -4
- package/src/kernel/runtime/graphpack/embedded/pack.stories.generated.ts +3 -0
- package/src/kernel/runtime/graphpack/embedded.ts +37 -11
- package/src/kernel/runtime/graphpack/graphpack.test.ts +274 -6
- package/src/kernel/runtime/graphpack/hash.ts +3 -2
- package/src/kernel/runtime/graphpack/index.ts +1 -1
- package/src/kernel/runtime/graphpack/manifest.ts +114 -12
- package/src/kernel/runtime/graphpack/read.ts +32 -22
- package/src/kernel/runtime/graphpack/schemas.ts +74 -0
- package/src/kernel/runtime/graphpack/stories.ts +84 -0
- package/src/kernel/runtime/graphpack/types.ts +72 -45
- package/src/kernel/runtime/loadSession.ts +4 -12
- package/src/kernel/runtime/paths.ts +64 -15
- package/src/kernel/runtime/probe.test.ts +8 -4
- package/src/kernel/runtime/readEntity.ts +6 -5
- package/src/kernel/runtime/refs/gitOps.ts +1 -19
- package/src/kernel/runtime/refs/parseRef.ts +7 -7
- package/src/kernel/runtime/refs/resolve.ts +141 -65
- package/src/kernel/runtime/resolveSources.test.ts +63 -24
- package/src/kernel/runtime/resolveSources.ts +36 -25
- package/src/kernel/runtime/storeReadiness.ts +62 -0
- package/src/kernel/runtime/storeless.test.ts +1 -1
- package/src/kernel/runtime/types.ts +1 -1
- package/src/kernel/runtime/wasmEmbed.test.ts +25 -20
- package/src/kernel/spec/emitReference.test.ts +196 -0
- package/src/kernel/spec/emitReference.ts +624 -0
- package/src/kernel/spec/emitSurface.ts +13 -7
- package/src/kernel/spec/index.ts +2 -0
- package/src/kernel/spec/surfaceConformance.test.ts +2 -2
- package/src/kernel/spec/surfaceConformance.ts +8 -0
- package/src/kernel/spec/types.ts +29 -9
- package/src/kernel/spec/validate.ts +2 -1
- package/src/kernel/vocabulary.test.ts +164 -0
- package/src/kernel/vocabulary.ts +146 -0
- package/src/testing/PARITY_GAPS.ts +9 -3
- package/src/testing/behavioral/README.md +3 -3
- package/src/testing/behavioral/autoLlm.e2e.test.ts +12 -2
- package/src/testing/behavioral/completion.test.ts +21 -8
- package/src/testing/behavioral/disclosure.test.ts +12 -0
- package/src/testing/behavioral/docExamples.test.ts +283 -0
- package/src/testing/behavioral/errorMatrix.mcp.test.ts +4 -3
- package/src/testing/behavioral/errorMatrix.storeless.test.ts +3 -2
- package/src/testing/behavioral/exitCodes.e2e.test.ts +6 -5
- package/src/testing/behavioral/harness.foundation.test.ts +14 -5
- package/src/testing/behavioral/journeys.cli.test.ts +116 -73
- package/src/testing/behavioral/journeys.defaultPack.test.ts +477 -0
- package/src/testing/behavioral/journeys.firstInstall.test.ts +167 -0
- package/src/testing/behavioral/journeys.interactiveTty.e2e.test.ts +66 -0
- package/src/testing/behavioral/journeys.livePack.e2e.test.ts +129 -0
- package/src/testing/behavioral/journeys.mcpLifecycle.mcp.test.ts +179 -0
- package/src/testing/behavioral/journeys.packageStories.test.ts +188 -0
- package/src/testing/behavioral/liveReadSurface.ts +4 -3
- package/src/testing/behavioral/mcpSurface.test.ts +10 -5
- package/src/testing/behavioral/staleCommands.test.ts +86 -0
- package/src/testing/eval/__snapshots__/eval.test.ts.snap +9 -9
- package/src/testing/eval/cases/assembly.ts +16 -9
- package/src/testing/eval/cases/effectful.ts +31 -16
- package/src/testing/eval/cases/readNouns.ts +23 -18
- package/src/testing/eval/cases/stable.ts +5 -1
- package/src/testing/eval/eval.test.ts +7 -1
- package/src/testing/fixtures/fixtureCapability.ts +201 -1
- package/src/testing/fixtures/graph/canonical.ts +6 -3
- package/src/testing/fixtures/graph/defaultPack.ts +179 -0
- package/src/testing/fixtures/graph/owlExport.ts +97 -0
- package/src/testing/helpers/extractSnippets.ts +29 -0
- package/src/testing/helpers/fixtureGraph.ts +33 -16
- package/src/testing/helpers/golden.ts +1 -1
- package/src/testing/helpers/packRuntime.ts +2 -3
- package/src/testing/helpers/projectCli.ts +2 -1
- package/src/testing/helpers/projectMcp.ts +6 -0
- package/src/testing/perf/budgets.test.ts +4 -0
- package/src/testing/perf/budgets.ts +22 -7
- package/src/testing/perf/globalSetup.ts +121 -6
- package/src/types.ts +2 -2
- package/src/capabilities/block/blockList.verb.ts +0 -150
- package/src/capabilities/block/index.ts +0 -25
- package/src/capabilities/block/pack.ts +0 -155
- package/src/capabilities/block/parity.test.ts +0 -180
- package/src/capabilities/block/tierChain.ts +0 -78
- package/src/capabilities/colophon/pragmaColophon.ts +0 -66
- package/src/capabilities/config/field.test.ts +0 -188
- package/src/capabilities/modifier/index.ts +0 -15
- package/src/capabilities/modifier/pack.ts +0 -64
- package/src/capabilities/standard/index.ts +0 -18
- package/src/capabilities/standard/pack.ts +0 -128
- package/src/capabilities/tier/index.ts +0 -19
- package/src/capabilities/tier/lookup.render.ts +0 -34
- package/src/capabilities/tier/lookup.test.ts +0 -68
- package/src/capabilities/tier/lookup.verb.ts +0 -44
- package/src/capabilities/tier/pack.ts +0 -28
- package/src/capabilities/tier/runLookup.ts +0 -59
- package/src/capabilities/token/addConfig.render.ts +0 -27
- package/src/capabilities/token/addConfig.test.ts +0 -62
- package/src/capabilities/token/addConfig.verb.ts +0 -40
- package/src/capabilities/token/index.ts +0 -19
- package/src/capabilities/token/pack.ts +0 -58
- package/src/capabilities/token/runAddConfig.ts +0 -95
- package/src/kernel/completion/bashDrive.test.ts +0 -73
- package/src/kernel/runtime/graphpack/embedded/sample.ttl +0 -39
- package/src/kernel/runtime/lock.ts +0 -90
|
@@ -1,13 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The embedded
|
|
3
|
-
*
|
|
4
|
-
* `scripts/
|
|
5
|
-
*
|
|
6
|
-
* install answers store-backed reads
|
|
2
|
+
* The embedded pack: the distribution's own graph, compiled from the packs
|
|
3
|
+
* `pragma.conf.ts` declares and inlined into the binary as escaped strings
|
|
4
|
+
* (`pack.generated.ts`, produced by `scripts/bundle.ts`). It is the store the
|
|
5
|
+
* CLI boots when the user has not pinned their own packs and has not built
|
|
6
|
+
* anything yet — so a fresh install answers real store-backed reads with no
|
|
7
|
+
* network, no cache, and no git credentials.
|
|
8
|
+
*
|
|
9
|
+
* It is a SNAPSHOT, not a substitute for `sources update`: the manifest records
|
|
10
|
+
* which upstream revisions it was compiled from, and both `sources status` and
|
|
11
|
+
* `doctor` report that rather than claiming the store is up to date. A project
|
|
12
|
+
* that declares its own packs and has not built them is never served this graph:
|
|
13
|
+
* every read, `info`, `sources status`, `doctor` and the MCP prompt/resource
|
|
14
|
+
* surfaces take {@link resolveSources}' answer and refuse. The one exception is
|
|
15
|
+
* the shell-completion fast path, which cannot read config at all (see
|
|
16
|
+
* `kernel/completion/entitySource.ts`) and so still offers this graph's names as
|
|
17
|
+
* completion candidates — candidates, never content.
|
|
7
18
|
*
|
|
8
19
|
* The inlined strings are materialized into the ordinary content-addressed pack
|
|
9
20
|
* cache on first use and then read back through {@link readPack}, so the
|
|
10
|
-
* embedded pack and a built pack share one boot path.
|
|
21
|
+
* embedded pack and a built pack share one boot path. The story records live in
|
|
22
|
+
* their OWN generated module (like the index): dispatch reads them on every
|
|
23
|
+
* command, and pulling them out of `pack.generated.ts` would load its ~1.9 MB
|
|
24
|
+
* of n-quads with them — a measured +28 ms on every invocation. Inlining as JS strings
|
|
11
25
|
* (rather than file assets) guarantees the content survives `bun build
|
|
12
26
|
* --compile` with no asset-embedding step.
|
|
13
27
|
*/
|
|
@@ -24,18 +38,29 @@ import { join } from "node:path";
|
|
|
24
38
|
import { packDir, packsCacheDir } from "../paths.js";
|
|
25
39
|
import { dataNq, manifestJson, schemaJson } from "./embedded/pack.generated.js";
|
|
26
40
|
import { indexJson } from "./embedded/pack.index.generated.js";
|
|
41
|
+
import { storiesJson } from "./embedded/pack.stories.generated.js";
|
|
27
42
|
import { packIsComplete } from "./manifest.js";
|
|
43
|
+
import { manifestSchema } from "./schemas.js";
|
|
28
44
|
import {
|
|
29
45
|
DATA_FILE,
|
|
30
46
|
INDEX_FILE,
|
|
31
47
|
MANIFEST_FILE,
|
|
32
|
-
|
|
48
|
+
type Manifest,
|
|
33
49
|
SCHEMA_FILE,
|
|
50
|
+
STORIES_FILE,
|
|
34
51
|
} from "./types.js";
|
|
35
52
|
|
|
36
|
-
/**
|
|
37
|
-
|
|
38
|
-
|
|
53
|
+
/**
|
|
54
|
+
* The embedded pack's manifest, parsed from the inlined string.
|
|
55
|
+
*
|
|
56
|
+
* Carries the provenance `sources status` and `doctor` report — `sourceRef`
|
|
57
|
+
* (which upstream revisions the snapshot was compiled from), `createdAt`, the
|
|
58
|
+
* counts — plus the `contentHash` that names its cache directory. Reading it
|
|
59
|
+
* costs one `JSON.parse` of a ~1 KB string, so neither surface has to
|
|
60
|
+
* materialize the 1.9 MB pack just to describe it.
|
|
61
|
+
*/
|
|
62
|
+
export function embeddedManifest(): Manifest {
|
|
63
|
+
return manifestSchema.parse(JSON.parse(manifestJson));
|
|
39
64
|
}
|
|
40
65
|
|
|
41
66
|
/**
|
|
@@ -48,7 +73,7 @@ export function embeddedContentHash(): string {
|
|
|
48
73
|
* @note Impure — writes the inlined pack files into the cache.
|
|
49
74
|
*/
|
|
50
75
|
export function materializeEmbeddedPack(): string {
|
|
51
|
-
const dir = packDir(
|
|
76
|
+
const dir = packDir(embeddedManifest().contentHash);
|
|
52
77
|
if (packIsComplete(dir)) return dir;
|
|
53
78
|
|
|
54
79
|
mkdirSync(packsCacheDir(), { recursive: true });
|
|
@@ -57,6 +82,7 @@ export function materializeEmbeddedPack(): string {
|
|
|
57
82
|
writeFileSync(join(temp, DATA_FILE), dataNq);
|
|
58
83
|
writeFileSync(join(temp, SCHEMA_FILE), schemaJson);
|
|
59
84
|
writeFileSync(join(temp, INDEX_FILE), indexJson);
|
|
85
|
+
writeFileSync(join(temp, STORIES_FILE), storiesJson);
|
|
60
86
|
// Written last — the completeness marker.
|
|
61
87
|
writeFileSync(join(temp, MANIFEST_FILE), manifestJson);
|
|
62
88
|
if (!packIsComplete(dir)) {
|
|
@@ -1,12 +1,30 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdtempSync,
|
|
4
|
+
readdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
rmSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from "node:fs";
|
|
2
9
|
import { tmpdir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
10
|
+
import { basename, join } from "node:path";
|
|
4
11
|
import { executeLocal } from "@canonical/ke-graphql";
|
|
5
12
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
6
13
|
import { buildPack } from "./build.js";
|
|
14
|
+
import { embeddedManifest, materializeEmbeddedPack } from "./embedded.js";
|
|
7
15
|
import { contentHash } from "./hash.js";
|
|
16
|
+
import { packIsComplete, validateManifest } from "./manifest.js";
|
|
8
17
|
import { readPack } from "./read.js";
|
|
9
|
-
import {
|
|
18
|
+
import { manifestSchema } from "./schemas.js";
|
|
19
|
+
import { activeStories } from "./stories.js";
|
|
20
|
+
import type { PackIndex } from "./types.js";
|
|
21
|
+
import {
|
|
22
|
+
DATA_FILE,
|
|
23
|
+
INDEX_FILE,
|
|
24
|
+
MANIFEST_FILE,
|
|
25
|
+
SCHEMA_FILE,
|
|
26
|
+
STORIES_FILE,
|
|
27
|
+
} from "./types.js";
|
|
10
28
|
|
|
11
29
|
const PREFIXES = {
|
|
12
30
|
ex: "https://pragma.canonical.com/sample#",
|
|
@@ -26,12 +44,16 @@ ex:Button a ex:Component ; rdfs:label "Button" ; ex:componentName "Button" .
|
|
|
26
44
|
ex:Card a ex:Component ; rdfs:label "Card" ; ex:componentName "Card" .
|
|
27
45
|
`;
|
|
28
46
|
|
|
29
|
-
const build = (
|
|
47
|
+
const build = (
|
|
48
|
+
inputs: { path: string; content: string }[],
|
|
49
|
+
stories?: { path: string; content: string }[],
|
|
50
|
+
) =>
|
|
30
51
|
buildPack(inputs, {
|
|
31
52
|
name: "test-pack",
|
|
32
53
|
version: "0.0.0",
|
|
33
54
|
sourceRef: "test:inline",
|
|
34
55
|
prefixes: PREFIXES,
|
|
56
|
+
...(stories === undefined ? {} : { stories }),
|
|
35
57
|
});
|
|
36
58
|
|
|
37
59
|
let savedCacheHome: string | undefined;
|
|
@@ -49,12 +71,22 @@ afterAll(() => {
|
|
|
49
71
|
});
|
|
50
72
|
|
|
51
73
|
describe("graphpack round-trip (PROTECTED)", () => {
|
|
52
|
-
it("builds the
|
|
74
|
+
it("builds the five artifact files and reuses a cached pack", async () => {
|
|
53
75
|
const result = await build([{ path: "a.ttl", content: TTL }]);
|
|
54
76
|
expect(result.reused).toBe(false);
|
|
55
|
-
for (const file of [
|
|
77
|
+
for (const file of [
|
|
78
|
+
DATA_FILE,
|
|
79
|
+
SCHEMA_FILE,
|
|
80
|
+
INDEX_FILE,
|
|
81
|
+
STORIES_FILE,
|
|
82
|
+
MANIFEST_FILE,
|
|
83
|
+
]) {
|
|
56
84
|
expect(existsSync(join(result.dir, file))).toBe(true);
|
|
57
85
|
}
|
|
86
|
+
// Written even when the packages ship none — an OPTIONAL artifact would put
|
|
87
|
+
// the same condition in all three modules that name the set, which is how a
|
|
88
|
+
// pack ends up claiming stories its directory does not hold.
|
|
89
|
+
expect(readFileSync(join(result.dir, STORIES_FILE), "utf-8")).toBe("[]");
|
|
58
90
|
|
|
59
91
|
// A second build over identical inputs is a pure cache hit — no rebuild.
|
|
60
92
|
const again = await build([{ path: "a.ttl", content: TTL }]);
|
|
@@ -103,6 +135,131 @@ describe("graphpack round-trip (PROTECTED)", () => {
|
|
|
103
135
|
});
|
|
104
136
|
});
|
|
105
137
|
|
|
138
|
+
describe("the committed embedded pack (PROTECTED)", () => {
|
|
139
|
+
it("materializes exactly the files buildPack produces", async () => {
|
|
140
|
+
// The artifact set is named once, in types.ts, but THREE modules must obey
|
|
141
|
+
// it: buildPack writes them, packIsComplete gates on them, and
|
|
142
|
+
// materializeEmbeddedPack writes them back out. A SIXTH artifact added to
|
|
143
|
+
// only some of those yields a pack whose content hash claims more than its
|
|
144
|
+
// directory holds — which the next build then reuses, silently dropping the
|
|
145
|
+
// difference. Comparing the two directories catches that on the day it lands.
|
|
146
|
+
const built = await build([{ path: "a.ttl", content: TTL }]);
|
|
147
|
+
expect(readdirSync(materializeEmbeddedPack()).sort()).toEqual(
|
|
148
|
+
readdirSync(built.dir).sort(),
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("is self-consistent: complete, content-addressed, and non-empty", () => {
|
|
153
|
+
// No network, so CI runs it: the committed strings really do materialize a
|
|
154
|
+
// bootable pack whose parts agree with each other.
|
|
155
|
+
const dir = materializeEmbeddedPack();
|
|
156
|
+
const manifest = embeddedManifest();
|
|
157
|
+
expect(packIsComplete(dir)).toBe(true);
|
|
158
|
+
expect(basename(dir)).toBe(manifest.contentHash);
|
|
159
|
+
const index = JSON.parse(
|
|
160
|
+
readFileSync(join(dir, INDEX_FILE), "utf-8"),
|
|
161
|
+
) as PackIndex;
|
|
162
|
+
expect(index.contentHash).toBe(manifest.contentHash);
|
|
163
|
+
expect(manifest.tripleCount ?? 0).toBeGreaterThan(0);
|
|
164
|
+
expect(manifest.entityCount ?? 0).toBeGreaterThan(0);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("graphpack carried stories (PROTECTED)", () => {
|
|
169
|
+
const STORY = {
|
|
170
|
+
path: "pkg/stories/recipe.json",
|
|
171
|
+
content: '{"noun":"recipe"}',
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
it("hashes stories as sources and round-trips them byte-for-byte", async () => {
|
|
175
|
+
const inputs = [{ path: "a.ttl", content: TTL }];
|
|
176
|
+
const without = await build(inputs);
|
|
177
|
+
const withStory = await build(inputs, [STORY]);
|
|
178
|
+
|
|
179
|
+
// A story-only edit is a NEW pack: the same RDF with a story attached must
|
|
180
|
+
// not reuse the pack built without it.
|
|
181
|
+
expect(withStory.contentHash).not.toBe(without.contentHash);
|
|
182
|
+
expect(
|
|
183
|
+
JSON.parse(readFileSync(join(withStory.dir, STORIES_FILE), "utf-8")),
|
|
184
|
+
).toEqual([{ source: STORY.path, content: STORY.content }]);
|
|
185
|
+
|
|
186
|
+
// …and rebuilding with the same story is a pure cache hit.
|
|
187
|
+
expect((await build(inputs, [STORY])).reused).toBe(true);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("a pack directory missing stories.json is incomplete", async () => {
|
|
191
|
+
// The migration path: a pack built by the previous kernel has four files,
|
|
192
|
+
// so it is refused (→ `pragma sources update`) rather than reused as if it
|
|
193
|
+
// carried the stories its hash covers.
|
|
194
|
+
const { dir } = await build([{ path: "a.ttl", content: TTL }]);
|
|
195
|
+
expect(packIsComplete(dir)).toBe(true);
|
|
196
|
+
rmSync(join(dir, STORIES_FILE));
|
|
197
|
+
expect(packIsComplete(dir)).toBe(false);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("a non-array stories.json is incomplete, and rebuilds instead of silently losing nouns", async () => {
|
|
201
|
+
// Size alone let a non-empty but non-ARRAY `stories.json` pass, so
|
|
202
|
+
// `buildPack` reused the directory and every package-declared noun vanished
|
|
203
|
+
// while `sources update` reported success. The shape gate turns that into an
|
|
204
|
+
// ordinary torn pack: refused, then rebuilt.
|
|
205
|
+
const inputs = [{ path: "a.ttl", content: TTL }];
|
|
206
|
+
const { dir } = await build(inputs, [STORY]);
|
|
207
|
+
expect(packIsComplete(dir)).toBe(true);
|
|
208
|
+
|
|
209
|
+
for (const corrupt of [
|
|
210
|
+
'{"noun":"recipe"}',
|
|
211
|
+
'"a string"',
|
|
212
|
+
"null",
|
|
213
|
+
"7",
|
|
214
|
+
"{",
|
|
215
|
+
]) {
|
|
216
|
+
writeFileSync(join(dir, STORIES_FILE), corrupt);
|
|
217
|
+
expect(packIsComplete(dir), corrupt).toBe(false);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// …and the rebuild is a REBUILD, not a reuse of the corrupt directory: the
|
|
221
|
+
// stories come back, so the nouns come back.
|
|
222
|
+
const rebuilt = await build(inputs, [STORY]);
|
|
223
|
+
expect(rebuilt.reused).toBe(false);
|
|
224
|
+
expect(packIsComplete(rebuilt.dir)).toBe(true);
|
|
225
|
+
expect(
|
|
226
|
+
JSON.parse(readFileSync(join(rebuilt.dir, STORIES_FILE), "utf-8")),
|
|
227
|
+
).toEqual([{ source: STORY.path, content: STORY.content }]);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("an EMPTY stories array stays complete — it is the ordinary no-stories pack", async () => {
|
|
231
|
+
// The gate is shape, not emptiness: `[]` is what a pack with no package
|
|
232
|
+
// stories legitimately carries, and refusing it would refuse most packs.
|
|
233
|
+
const { dir } = await build([{ path: "a.ttl", content: TTL }]);
|
|
234
|
+
expect(readFileSync(join(dir, STORIES_FILE), "utf-8")).toBe("[]");
|
|
235
|
+
expect(packIsComplete(dir)).toBe(true);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("reads only records shaped { source, content } from a pack directory", async () => {
|
|
239
|
+
// `stories.json` lives in a user-writable cache. An element that is not a
|
|
240
|
+
// record used to be cast straight through and reported as
|
|
241
|
+
// `Ignored story undefined: …` — a diagnostic naming no file, repeated on
|
|
242
|
+
// every command. Records are checked, so a corrupt entry simply is not one.
|
|
243
|
+
const { dir } = await build([{ path: "a.ttl", content: TTL }], [STORY]);
|
|
244
|
+
writeFileSync(
|
|
245
|
+
join(dir, STORIES_FILE),
|
|
246
|
+
JSON.stringify([
|
|
247
|
+
1,
|
|
248
|
+
null,
|
|
249
|
+
{ source: "x" },
|
|
250
|
+
{ source: 1, content: 2 },
|
|
251
|
+
{
|
|
252
|
+
source: STORY.path,
|
|
253
|
+
content: STORY.content,
|
|
254
|
+
},
|
|
255
|
+
]),
|
|
256
|
+
);
|
|
257
|
+
expect(activeStories({ kind: "pack", dir, contentHash: "z" })).toEqual([
|
|
258
|
+
{ source: STORY.path, content: STORY.content },
|
|
259
|
+
]);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
106
263
|
describe("graphpack hash stability (PROTECTED)", () => {
|
|
107
264
|
it("is order-independent and content-sensitive", async () => {
|
|
108
265
|
const a = { path: "a.ttl", content: "ex:one a ex:X ." };
|
|
@@ -128,6 +285,117 @@ describe("graphpack hash stability (PROTECTED)", () => {
|
|
|
128
285
|
});
|
|
129
286
|
});
|
|
130
287
|
|
|
288
|
+
describe("graphpack manifest — persisted counts (A9/A10)", () => {
|
|
289
|
+
it("records tripleCount and a distinct-abox entityCount", async () => {
|
|
290
|
+
const { dir } = await build([{ path: "a.ttl", content: TTL }]);
|
|
291
|
+
const manifest = JSON.parse(
|
|
292
|
+
readFileSync(join(dir, MANIFEST_FILE), "utf-8"),
|
|
293
|
+
) as { tripleCount?: number; entityCount?: number };
|
|
294
|
+
expect(typeof manifest.tripleCount).toBe("number");
|
|
295
|
+
expect(manifest.tripleCount ?? 0).toBeGreaterThan(0);
|
|
296
|
+
// TTL declares two individuals (ex:Button, ex:Card) → two abox subjects.
|
|
297
|
+
expect(manifest.entityCount).toBe(2);
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
describe("the manifest hand validator agrees with the schema (PROTECTED)", () => {
|
|
302
|
+
// `readManifest` is on the storeless fast path, so it validates structurally
|
|
303
|
+
// instead of importing zod. `manifestSchema` stays the executable spec: this
|
|
304
|
+
// pins the two to accept EXACTLY the same payloads, in both directions, so a
|
|
305
|
+
// rule changed in one and not the other fails here rather than at a read.
|
|
306
|
+
const VALID = {
|
|
307
|
+
name: "pack",
|
|
308
|
+
version: "1.0.0",
|
|
309
|
+
sourceRef: "git+https://example.invalid#main",
|
|
310
|
+
contentHash: "abc123",
|
|
311
|
+
prefixes: { ex: "https://example.invalid#" },
|
|
312
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
const cases: Array<[string, unknown]> = [
|
|
316
|
+
["the minimal valid manifest", VALID],
|
|
317
|
+
["with both optional counts", { ...VALID, tripleCount: 3, entityCount: 2 }],
|
|
318
|
+
["with only tripleCount", { ...VALID, tripleCount: 0 }],
|
|
319
|
+
["with only entityCount", { ...VALID, entityCount: 0 }],
|
|
320
|
+
["with empty prefixes", { ...VALID, prefixes: {} }],
|
|
321
|
+
["with an unknown key", { ...VALID, extra: "ignored" }],
|
|
322
|
+
["a missing required field", { ...VALID, name: undefined }],
|
|
323
|
+
["a required field of the wrong type", { ...VALID, version: 2 }],
|
|
324
|
+
["a non-string sourceRef", { ...VALID, sourceRef: null }],
|
|
325
|
+
["a non-string contentHash", { ...VALID, contentHash: 7 }],
|
|
326
|
+
["a non-string createdAt", { ...VALID, createdAt: 0 }],
|
|
327
|
+
["prefixes as an array", { ...VALID, prefixes: [] }],
|
|
328
|
+
["prefixes holding a non-string", { ...VALID, prefixes: { ex: 1 } }],
|
|
329
|
+
["prefixes as null", { ...VALID, prefixes: null }],
|
|
330
|
+
["a non-number tripleCount", { ...VALID, tripleCount: "3" }],
|
|
331
|
+
["a non-number entityCount", { ...VALID, entityCount: "2" }],
|
|
332
|
+
["a JSON array", [VALID]],
|
|
333
|
+
["a JSON string", "manifest"],
|
|
334
|
+
["null", null],
|
|
335
|
+
["a number", 4],
|
|
336
|
+
];
|
|
337
|
+
|
|
338
|
+
for (const [label, payload] of cases) {
|
|
339
|
+
it(`accepts/rejects ${label} exactly as manifestSchema does`, () => {
|
|
340
|
+
const bySchema = manifestSchema.safeParse(payload);
|
|
341
|
+
const byHand = validateManifest(payload);
|
|
342
|
+
expect(byHand !== undefined, label).toBe(bySchema.success);
|
|
343
|
+
// Agreement on the VALUE too, which is where strip semantics live: an
|
|
344
|
+
// unknown key must be dropped by both, not carried by one.
|
|
345
|
+
if (bySchema.success) expect(byHand).toEqual(bySchema.data);
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
describe("graphpack read — truncated data cache (A9)", () => {
|
|
351
|
+
it("a truncated-but-non-empty data.nq surfaces STORE_UNAVAILABLE", async () => {
|
|
352
|
+
// A UNIQUE graph so corrupting its cache never poisons the shared TTL pack.
|
|
353
|
+
const uniqueTtl = `${TTL}\nex:Truncated a ex:Component ; rdfs:label "Truncated" .\n`;
|
|
354
|
+
const { dir } = await build([{ path: "trunc.ttl", content: uniqueTtl }]);
|
|
355
|
+
const dataPath = join(dir, DATA_FILE);
|
|
356
|
+
const lines = readFileSync(dataPath, "utf-8")
|
|
357
|
+
.split("\n")
|
|
358
|
+
.filter((line) => line.trim() !== "");
|
|
359
|
+
expect(lines.length).toBeGreaterThan(1);
|
|
360
|
+
// Drop the last statement: the dump is now a PARTIAL graph — still
|
|
361
|
+
// non-empty (so it passes the size>0 completeness gate) but fewer triples
|
|
362
|
+
// than the manifest recorded, which the boot cross-check must catch.
|
|
363
|
+
writeFileSync(dataPath, `${lines.slice(0, -1).join("\n")}\n`);
|
|
364
|
+
|
|
365
|
+
let caught: unknown;
|
|
366
|
+
try {
|
|
367
|
+
const session = await readPack(dir);
|
|
368
|
+
session.store.dispose();
|
|
369
|
+
} catch (error) {
|
|
370
|
+
caught = error;
|
|
371
|
+
}
|
|
372
|
+
expect(caught).toMatchObject({ code: "STORE_UNAVAILABLE" });
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("tolerates a benign superset (actual > recorded), not corruption", async () => {
|
|
376
|
+
// A future ke counting change could load MORE triples than the manifest
|
|
377
|
+
// recorded. That is not truncation, so boot must NOT reject it — otherwise
|
|
378
|
+
// one counting change trips a fleet-wide false STORE_UNAVAILABLE. Simulate by
|
|
379
|
+
// lowering the manifest's tripleCount below the dump's actual count.
|
|
380
|
+
const uniqueTtl = `${TTL}\nex:Superset a ex:Component ; rdfs:label "Superset" .\n`;
|
|
381
|
+
const { dir } = await build([{ path: "superset.ttl", content: uniqueTtl }]);
|
|
382
|
+
const manifestPath = join(dir, MANIFEST_FILE);
|
|
383
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as {
|
|
384
|
+
tripleCount: number;
|
|
385
|
+
};
|
|
386
|
+
writeFileSync(
|
|
387
|
+
manifestPath,
|
|
388
|
+
JSON.stringify({ ...manifest, tripleCount: manifest.tripleCount - 1 }),
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
// The booted store holds one MORE triple than the (lowered) manifest — a
|
|
392
|
+
// superset. On the pre-fix exact-equality guard this threw; now it boots.
|
|
393
|
+
const session = await readPack(dir);
|
|
394
|
+
expect(session.index.entities.length).toBeGreaterThan(0);
|
|
395
|
+
session.store.dispose();
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
|
|
131
399
|
describe("graphpack read — incomplete pack recovery (CLI + MCP)", () => {
|
|
132
400
|
it("an incomplete pack is STORE_UNAVAILABLE with a `sources_update` tool recovery", async () => {
|
|
133
401
|
// A dir with no manifest is an incomplete pack — readPack rejects before any
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
*
|
|
5
5
|
* 1. {@link contentHash} — a SHA-256 (ke-graphql's `sha256Hex`) over the
|
|
6
6
|
* canonicalized *source inputs* (sorted by path, path + content joined with
|
|
7
|
-
* fixed delimiters). It names the pack's cache directory and is
|
|
8
|
-
*
|
|
7
|
+
* fixed delimiters). It names the pack's cache directory and is what a
|
|
8
|
+
* project's active-pack pointer holds, so the same sources always resolve to
|
|
9
|
+
* the same directory and a
|
|
9
10
|
* changed source is a new directory (free invalidation). Hashing the sorted
|
|
10
11
|
* TTL *inputs* — never the `data.nq` dump, whose n-quads line order is not
|
|
11
12
|
* canonical — keeps it deterministic.
|
|
@@ -10,7 +10,7 @@ export type {
|
|
|
10
10
|
} from "./build.js";
|
|
11
11
|
export { buildPack } from "./build.js";
|
|
12
12
|
export { buildIndex } from "./buildIndex.js";
|
|
13
|
-
export {
|
|
13
|
+
export { embeddedManifest, materializeEmbeddedPack } from "./embedded.js";
|
|
14
14
|
export { contentHash, type HashInput, hashSources } from "./hash.js";
|
|
15
15
|
export { packIsComplete, readManifest } from "./manifest.js";
|
|
16
16
|
export { readPack } from "./read.js";
|
|
@@ -3,6 +3,13 @@
|
|
|
3
3
|
* directory without a valid manifest is a torn build (writes land in a temp
|
|
4
4
|
* directory and the manifest is renamed in last), so an absent or invalid
|
|
5
5
|
* manifest means "treat the pack as not there".
|
|
6
|
+
*
|
|
7
|
+
* This module is on the storeless FAST PATH (`resolveSources` → `packIsComplete`
|
|
8
|
+
* → here, while `__complete` builds the command tree), so it validates the ~1 KB
|
|
9
|
+
* manifest structurally instead of importing zod to do it — that import was
|
|
10
|
+
* ~3–4 ms of a ~30 ms budget. {@link validateManifest} is held to the schema's
|
|
11
|
+
* behaviour by `graphpack.test.ts`, which pins the two to accept the same inputs
|
|
12
|
+
* in both directions; `schemas.ts` remains the executable specification.
|
|
6
13
|
*/
|
|
7
14
|
|
|
8
15
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
@@ -12,10 +19,61 @@ import {
|
|
|
12
19
|
INDEX_FILE,
|
|
13
20
|
MANIFEST_FILE,
|
|
14
21
|
type Manifest,
|
|
15
|
-
manifestSchema,
|
|
16
22
|
SCHEMA_FILE,
|
|
23
|
+
STORIES_FILE,
|
|
17
24
|
} from "./types.js";
|
|
18
25
|
|
|
26
|
+
/** Whether `value` is a plain object of strings — the `prefixes` map's shape. */
|
|
27
|
+
function isStringRecord(value: unknown): value is Record<string, string> {
|
|
28
|
+
return (
|
|
29
|
+
typeof value === "object" &&
|
|
30
|
+
value !== null &&
|
|
31
|
+
!Array.isArray(value) &&
|
|
32
|
+
Object.values(value).every((entry) => typeof entry === "string")
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Validate a parsed `manifest.json` payload structurally.
|
|
38
|
+
*
|
|
39
|
+
* Mirrors `manifestSchema` exactly, including its STRIP semantics: a valid
|
|
40
|
+
* manifest is reconstructed field by field, so an unknown key in the file is
|
|
41
|
+
* dropped rather than carried into the returned object (zod's `z.object`
|
|
42
|
+
* default, which several callers rely on when they re-serialize a manifest).
|
|
43
|
+
*
|
|
44
|
+
* @param value - The `JSON.parse` result to validate.
|
|
45
|
+
* @returns The manifest, or `undefined` when the payload does not conform.
|
|
46
|
+
*/
|
|
47
|
+
export function validateManifest(value: unknown): Manifest | undefined {
|
|
48
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
const raw = value as Record<string, unknown>;
|
|
52
|
+
const { tripleCount, entityCount } = raw;
|
|
53
|
+
if (
|
|
54
|
+
typeof raw.name !== "string" ||
|
|
55
|
+
typeof raw.version !== "string" ||
|
|
56
|
+
typeof raw.sourceRef !== "string" ||
|
|
57
|
+
typeof raw.contentHash !== "string" ||
|
|
58
|
+
typeof raw.createdAt !== "string" ||
|
|
59
|
+
!isStringRecord(raw.prefixes) ||
|
|
60
|
+
(tripleCount !== undefined && typeof tripleCount !== "number") ||
|
|
61
|
+
(entityCount !== undefined && typeof entityCount !== "number")
|
|
62
|
+
) {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
name: raw.name,
|
|
67
|
+
version: raw.version,
|
|
68
|
+
sourceRef: raw.sourceRef,
|
|
69
|
+
contentHash: raw.contentHash,
|
|
70
|
+
prefixes: raw.prefixes,
|
|
71
|
+
createdAt: raw.createdAt,
|
|
72
|
+
...(tripleCount === undefined ? {} : { tripleCount }),
|
|
73
|
+
...(entityCount === undefined ? {} : { entityCount }),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
19
77
|
/**
|
|
20
78
|
* Read and validate a pack directory's manifest.
|
|
21
79
|
*
|
|
@@ -27,29 +85,73 @@ export function readManifest(dir: string): Manifest | undefined {
|
|
|
27
85
|
const path = join(dir, MANIFEST_FILE);
|
|
28
86
|
if (!existsSync(path)) return undefined;
|
|
29
87
|
try {
|
|
30
|
-
return
|
|
88
|
+
return validateManifest(JSON.parse(readFileSync(path, "utf-8")));
|
|
31
89
|
} catch {
|
|
32
90
|
return undefined;
|
|
33
91
|
}
|
|
34
92
|
}
|
|
35
93
|
|
|
36
94
|
/** Whether a pack directory holds a complete pack: a valid manifest AND every
|
|
37
|
-
* non-empty
|
|
38
|
-
*
|
|
39
|
-
* manifest beside a missing/truncated `data.nq` boots
|
|
40
|
-
* permanent, loss), and a torn or evicted
|
|
41
|
-
* dump intact) would be REUSED by
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* (
|
|
95
|
+
* non-empty content artifact — the `data.nq` dump, the extracted `schema.json`,
|
|
96
|
+
* the entity `index.json`, and the carried `stories.json`. The manifest alone is
|
|
97
|
+
* not enough: an intact manifest beside a missing/truncated `data.nq` boots
|
|
98
|
+
* EMPTY (a silent, then permanent, loss), and a torn or evicted
|
|
99
|
+
* `schema.json`/`index.json` (manifest + dump intact) would be REUSED by
|
|
100
|
+
* `buildPack` and then fail at BOOT as an internal error. `stories.json` is
|
|
101
|
+
* gated for the same reason and unconditionally: it is written even when empty
|
|
102
|
+
* (as `[]`, which is non-empty text), so a pack directory lacking it is one
|
|
103
|
+
* whose content hash covers stories the directory does not hold — reusing it
|
|
104
|
+
* would silently drop every package-declared noun. Requiring all four present +
|
|
105
|
+
* non-empty makes `buildPack` rebuild a torn pack and makes the boot decision
|
|
106
|
+
* surface STORE_UNAVAILABLE (the ordinary "not built" recovery) instead of a
|
|
107
|
+
* "please report this" crash. `stories.json` is additionally gated on its SHAPE
|
|
108
|
+
* — see {@link storiesAreReadable} for why it, and only it, is parsed here. */
|
|
45
109
|
export function packIsComplete(dir: string): boolean {
|
|
46
110
|
if (readManifest(dir) === undefined) return false;
|
|
47
|
-
for (const file of [DATA_FILE, SCHEMA_FILE, INDEX_FILE]) {
|
|
111
|
+
for (const file of [DATA_FILE, SCHEMA_FILE, INDEX_FILE, STORIES_FILE]) {
|
|
48
112
|
try {
|
|
49
113
|
if (statSync(join(dir, file)).size <= 0) return false;
|
|
50
114
|
} catch {
|
|
51
115
|
return false;
|
|
52
116
|
}
|
|
53
117
|
}
|
|
54
|
-
return
|
|
118
|
+
return storiesAreReadable(dir);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Whether `stories.json` holds what its reader expects: a JSON ARRAY.
|
|
123
|
+
*
|
|
124
|
+
* Size alone was not enough, and stories are the one artifact where that gap is
|
|
125
|
+
* SILENT. A non-empty but non-array `stories.json` passed the size gate, so
|
|
126
|
+
* `buildPack` REUSED the directory and every package-declared noun vanished
|
|
127
|
+
* while `sources update` reported success — with `parseRecords`' fix, without
|
|
128
|
+
* even an error line to attribute. Parsing it turns that into an incomplete
|
|
129
|
+
* pack: the boot decision reports the ordinary STORE_UNAVAILABLE and the next
|
|
130
|
+
* `buildPack` rebuilds, which is the recovery the other three artifacts already
|
|
131
|
+
* get.
|
|
132
|
+
*
|
|
133
|
+
* SHAPE ONLY, deliberately: `Array.isArray`, not a check of the records inside.
|
|
134
|
+
* A malformed or schema-invalid RECORD is the one guard in
|
|
135
|
+
* `packs/collect.validateStories`, which names it on stderr and under `doctor`
|
|
136
|
+
* — a story a third party got wrong must not make the whole pack unreadable.
|
|
137
|
+
*
|
|
138
|
+
* `data.nq` and `schema.json` stay SIZE-gated on purpose. A truncated `data.nq`
|
|
139
|
+
* is caught downstream by `readPack`'s A9 triple-count cross-check, which is
|
|
140
|
+
* stronger than any parse here; a corrupt `schema.json` still surfaces
|
|
141
|
+
* unclassified, and that gap is pinned separately rather than absorbed into this
|
|
142
|
+
* one — this is the fast path, and it parses only the artifact whose failure is
|
|
143
|
+
* otherwise invisible.
|
|
144
|
+
*
|
|
145
|
+
* @param dir - The pack directory.
|
|
146
|
+
* @returns Whether the stories artifact parses as an array.
|
|
147
|
+
* @note Impure — reads from disk.
|
|
148
|
+
*/
|
|
149
|
+
function storiesAreReadable(dir: string): boolean {
|
|
150
|
+
try {
|
|
151
|
+
return Array.isArray(
|
|
152
|
+
JSON.parse(readFileSync(join(dir, STORIES_FILE), "utf-8")),
|
|
153
|
+
);
|
|
154
|
+
} catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
55
157
|
}
|