@cleocode/contracts 2026.5.112 → 2026.5.113

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.
Files changed (98) hide show
  1. package/dist/__tests__/cli-meta-suggested-next.test.d.ts +21 -0
  2. package/dist/__tests__/cli-meta-suggested-next.test.d.ts.map +1 -0
  3. package/dist/__tests__/cli-meta-suggested-next.test.js +70 -0
  4. package/dist/__tests__/cli-meta-suggested-next.test.js.map +1 -0
  5. package/dist/config/__tests__/manifest.test.d.ts +13 -0
  6. package/dist/config/__tests__/manifest.test.d.ts.map +1 -0
  7. package/dist/config/__tests__/manifest.test.js +143 -0
  8. package/dist/config/__tests__/manifest.test.js.map +1 -0
  9. package/dist/config/manifest.d.ts +158 -0
  10. package/dist/config/manifest.d.ts.map +1 -0
  11. package/dist/config/manifest.js +123 -0
  12. package/dist/config/manifest.js.map +1 -0
  13. package/dist/docs/__tests__/provenance.test.d.ts +14 -0
  14. package/dist/docs/__tests__/provenance.test.d.ts.map +1 -0
  15. package/dist/docs/__tests__/provenance.test.js +579 -0
  16. package/dist/docs/__tests__/provenance.test.js.map +1 -0
  17. package/dist/docs/provenance.d.ts +731 -0
  18. package/dist/docs/provenance.d.ts.map +1 -0
  19. package/dist/docs/provenance.js +258 -0
  20. package/dist/docs/provenance.js.map +1 -0
  21. package/dist/docs-taxonomy.d.ts +24 -0
  22. package/dist/docs-taxonomy.d.ts.map +1 -1
  23. package/dist/docs-taxonomy.js +27 -0
  24. package/dist/docs-taxonomy.js.map +1 -1
  25. package/dist/doctor.d.ts +101 -0
  26. package/dist/doctor.d.ts.map +1 -1
  27. package/dist/errors.d.ts +4 -0
  28. package/dist/errors.d.ts.map +1 -1
  29. package/dist/errors.js +4 -0
  30. package/dist/errors.js.map +1 -1
  31. package/dist/exit-codes.d.ts +37 -0
  32. package/dist/exit-codes.d.ts.map +1 -1
  33. package/dist/exit-codes.js +37 -0
  34. package/dist/exit-codes.js.map +1 -1
  35. package/dist/index.d.ts +10 -3
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +7 -2
  38. package/dist/index.js.map +1 -1
  39. package/dist/invariants/__tests__/adr-056-release.test.d.ts +16 -0
  40. package/dist/invariants/__tests__/adr-056-release.test.d.ts.map +1 -0
  41. package/dist/invariants/__tests__/adr-056-release.test.js +88 -0
  42. package/dist/invariants/__tests__/adr-056-release.test.js.map +1 -0
  43. package/dist/invariants/__tests__/adr-070-orchestration.test.d.ts +22 -0
  44. package/dist/invariants/__tests__/adr-070-orchestration.test.d.ts.map +1 -0
  45. package/dist/invariants/__tests__/adr-070-orchestration.test.js +151 -0
  46. package/dist/invariants/__tests__/adr-070-orchestration.test.js.map +1 -0
  47. package/dist/invariants/adr-056-release.d.ts +42 -0
  48. package/dist/invariants/adr-056-release.d.ts.map +1 -0
  49. package/dist/invariants/adr-056-release.js +143 -0
  50. package/dist/invariants/adr-056-release.js.map +1 -0
  51. package/dist/invariants/adr-070-orchestration.d.ts +73 -0
  52. package/dist/invariants/adr-070-orchestration.d.ts.map +1 -0
  53. package/dist/invariants/adr-070-orchestration.js +290 -0
  54. package/dist/invariants/adr-070-orchestration.js.map +1 -0
  55. package/dist/invariants/index.d.ts +4 -1
  56. package/dist/invariants/index.d.ts.map +1 -1
  57. package/dist/invariants/index.js +11 -2
  58. package/dist/invariants/index.js.map +1 -1
  59. package/dist/operations/__tests__/input-contract.test.d.ts +20 -0
  60. package/dist/operations/__tests__/input-contract.test.d.ts.map +1 -0
  61. package/dist/operations/__tests__/input-contract.test.js +158 -0
  62. package/dist/operations/__tests__/input-contract.test.js.map +1 -0
  63. package/dist/operations/docs.d.ts +16 -0
  64. package/dist/operations/docs.d.ts.map +1 -1
  65. package/dist/operations/input-contract.d.ts +267 -0
  66. package/dist/operations/input-contract.d.ts.map +1 -0
  67. package/dist/operations/input-contract.js +29 -0
  68. package/dist/operations/input-contract.js.map +1 -0
  69. package/dist/templates/__tests__/manifest.test.d.ts +18 -0
  70. package/dist/templates/__tests__/manifest.test.d.ts.map +1 -0
  71. package/dist/templates/__tests__/manifest.test.js +123 -0
  72. package/dist/templates/__tests__/manifest.test.js.map +1 -0
  73. package/dist/templates/manifest.d.ts +186 -0
  74. package/dist/templates/manifest.d.ts.map +1 -0
  75. package/dist/templates/manifest.js +165 -0
  76. package/dist/templates/manifest.js.map +1 -0
  77. package/package.json +2 -2
  78. package/src/__tests__/cli-meta-suggested-next.test.ts +76 -0
  79. package/src/config/__tests__/manifest.test.ts +176 -0
  80. package/src/config/manifest.ts +218 -0
  81. package/src/docs/__tests__/__snapshots__/provenance.test.ts.snap +252 -0
  82. package/src/docs/__tests__/provenance.test.ts +648 -0
  83. package/src/docs/provenance.ts +601 -0
  84. package/src/docs-taxonomy.ts +55 -0
  85. package/src/doctor.ts +111 -0
  86. package/src/errors.ts +4 -0
  87. package/src/exit-codes.ts +39 -0
  88. package/src/index.ts +90 -0
  89. package/src/invariants/__tests__/adr-056-release.test.ts +102 -0
  90. package/src/invariants/__tests__/adr-070-orchestration.test.ts +186 -0
  91. package/src/invariants/adr-056-release.ts +154 -0
  92. package/src/invariants/adr-070-orchestration.ts +317 -0
  93. package/src/invariants/index.ts +11 -2
  94. package/src/operations/__tests__/input-contract.test.ts +188 -0
  95. package/src/operations/docs.ts +16 -0
  96. package/src/operations/input-contract.ts +301 -0
  97. package/src/templates/__tests__/manifest.test.ts +140 -0
  98. package/src/templates/manifest.ts +208 -0
@@ -0,0 +1,165 @@
1
+ /**
2
+ * TemplateManifest contract — SSoT for every template CLEO ships, installs,
3
+ * or rewrites during `cleo init` / `cleo upgrade` / `cleo scaffold-*`.
4
+ *
5
+ * A {@link TemplateManifestEntry} declares one template file: where its
6
+ * source lives in the monorepo, where it installs into a consumer project,
7
+ * how its placeholders are substituted, which placeholders it accepts, and
8
+ * how it MUST be reconciled when the shipped version moves ahead of the
9
+ * deployed copy.
10
+ *
11
+ * This contract is the leaf type. The CORE-side template registry
12
+ * (T9877) consumes it; the CLI verbs (`cleo init --workflows`,
13
+ * `cleo upgrade`, the scaffold sweeper) read the manifest at runtime to
14
+ * decide what to write, what to skip, and what to diff-prompt the user on.
15
+ *
16
+ * @example minimal workflow template entry
17
+ * ```ts
18
+ * import { TemplateManifestEntrySchema, type TemplateManifestEntry } from '@cleocode/contracts';
19
+ *
20
+ * const entry: TemplateManifestEntry = TemplateManifestEntrySchema.parse({
21
+ * id: 'ci.yml',
22
+ * kind: 'workflow',
23
+ * sourcePath: 'packages/core/templates/workflows/ci.yml.tmpl',
24
+ * installPath: '.github/workflows/ci.yml',
25
+ * substitution: 'regex-tmpl',
26
+ * placeholders: [
27
+ * {
28
+ * name: 'NODE_VERSION',
29
+ * source: 'project-context',
30
+ * sourcePath: 'engines.node',
31
+ * defaultValue: 24,
32
+ * },
33
+ * ],
34
+ * updateStrategy: 'overwrite-on-bump',
35
+ * });
36
+ * ```
37
+ *
38
+ * @task T9875
39
+ * @epic T9874
40
+ * @saga T9855
41
+ * @see ADR-076 (canonical doc routing — same boundary discipline applies here)
42
+ */
43
+ import { z } from 'zod';
44
+ // ─── TemplateKind ─────────────────────────────────────────────────────────────
45
+ /**
46
+ * Allowed categories of template the manifest can describe.
47
+ *
48
+ * - `workflow` — GitHub Actions / CI workflow files.
49
+ * - `config` — Configuration files (e.g. `.editorconfig`, `tsconfig.json`).
50
+ * - `agent` — Agent definition files shipped to `.claude/agents/` etc.
51
+ * - `skill` — Skill markdown shipped to `.claude/skills/` etc.
52
+ * - `provider` — Provider-specific bootstrap files (Anthropic, OpenAI, …).
53
+ * - `doc` — Documentation scaffolds (e.g. README.md, AGENTS.md).
54
+ */
55
+ export const TEMPLATE_KINDS = ['workflow', 'config', 'agent', 'skill', 'provider', 'doc'];
56
+ // ─── TemplateSubstitution ─────────────────────────────────────────────────────
57
+ /**
58
+ * Allowed substitution strategies a template can request.
59
+ *
60
+ * - `regex-tmpl` — `{{KEY}}` placeholder substitution via regex.
61
+ * - `static` — No substitution; the file is copied byte-for-byte.
62
+ * - `json-merge` — The template is parsed as JSON and merged into any
63
+ * existing JSON file at `installPath`; placeholders inside
64
+ * string values still go through `regex-tmpl`.
65
+ */
66
+ export const TEMPLATE_SUBSTITUTIONS = ['regex-tmpl', 'static', 'json-merge'];
67
+ // ─── UpdateStrategy ───────────────────────────────────────────────────────────
68
+ /**
69
+ * How an installed template is reconciled with the shipped copy on
70
+ * `cleo upgrade` / `cleo init --force`.
71
+ *
72
+ * - `overwrite-on-bump` — Always overwrite when the manifest version is
73
+ * newer than the deployed copy.
74
+ * - `diff-prompt` — Show a diff and ask the user before overwriting.
75
+ * - `immutable` — Never overwrite once installed.
76
+ * - `manifest-merge` — Apply structural merge (e.g. JSON deep-merge,
77
+ * respecting existing user keys).
78
+ */
79
+ export const TEMPLATE_UPDATE_STRATEGIES = [
80
+ 'overwrite-on-bump',
81
+ 'diff-prompt',
82
+ 'immutable',
83
+ 'manifest-merge',
84
+ ];
85
+ // ─── PlaceholderSpec ──────────────────────────────────────────────────────────
86
+ /**
87
+ * Allowed resolver sources a {@link PlaceholderSpec} can reference.
88
+ *
89
+ * - `project-context` — `.cleo/project-context.json` (detected ecosystem hints).
90
+ * - `project-info` — `.cleo/project-info.json` (per-project metadata).
91
+ * - `.cleo/config` — Per-project config (`<projectRoot>/.cleo/config.json`).
92
+ * - `~/.cleo/config` — User-global config (`$XDG_CONFIG_HOME/cleo/config.json`).
93
+ * - `tool-resolver` — Resolved via the per-language tool resolver
94
+ * (e.g. `pnpm` vs `npm` chosen by `primaryType`).
95
+ * - `literal` — Hard-coded value baked into the manifest entry.
96
+ */
97
+ export const PLACEHOLDER_SOURCES = [
98
+ 'project-context',
99
+ 'project-info',
100
+ '.cleo/config',
101
+ '~/.cleo/config',
102
+ 'tool-resolver',
103
+ 'literal',
104
+ ];
105
+ /**
106
+ * Zod schema for a single placeholder declaration on a template entry.
107
+ *
108
+ * Each placeholder is resolved at install time by reading `sourcePath` from
109
+ * the named `source`. If resolution returns `undefined`, the installer
110
+ * falls back to `defaultValue`; if neither yields a value, installation
111
+ * fails with `E_PLACEHOLDER_UNRESOLVED`.
112
+ */
113
+ export const PlaceholderSpecSchema = z.object({
114
+ /**
115
+ * Placeholder identifier as it appears in the template body
116
+ * (e.g. `NODE_VERSION` matches `{{NODE_VERSION}}`).
117
+ */
118
+ name: z.string().min(1, 'placeholder name must be non-empty'),
119
+ /** Resolver source the installer consults for this placeholder. */
120
+ source: z.enum(PLACEHOLDER_SOURCES),
121
+ /**
122
+ * Path expression evaluated against `source` (e.g. `engines.node` against
123
+ * `project-context`, `defaults.branchModel` against `.cleo/config`).
124
+ * For `literal` source, this MAY be the literal value's identifier.
125
+ */
126
+ sourcePath: z.string().min(1, 'placeholder sourcePath must be non-empty'),
127
+ /**
128
+ * Fallback value used when `source[sourcePath]` resolves to `undefined`.
129
+ * `null` is permitted to explicitly mark "no default — failure required".
130
+ */
131
+ defaultValue: z.union([z.string(), z.number(), z.boolean(), z.null()]).optional(),
132
+ });
133
+ // ─── TemplateManifestEntry ────────────────────────────────────────────────────
134
+ /**
135
+ * Zod schema for a single template manifest entry.
136
+ *
137
+ * Validates the full descriptor a template registry consumer needs to
138
+ * install a template file:
139
+ *
140
+ * - `id` — Stable identifier (kebab-case, matches install basename
141
+ * or a domain-unique slug).
142
+ * - `kind` — One of {@link TEMPLATE_KINDS}.
143
+ * - `sourcePath` — Repo-relative path of the template source.
144
+ * - `installPath` — Project-relative path where the rendered file lands.
145
+ * - `substitution` — One of {@link TEMPLATE_SUBSTITUTIONS}.
146
+ * - `placeholders` — Declared placeholders the installer must resolve.
147
+ * - `updateStrategy` — Reconciliation policy on upgrade.
148
+ */
149
+ export const TemplateManifestEntrySchema = z.object({
150
+ /** Stable identifier for this template entry. */
151
+ id: z.string().min(1, 'id must be non-empty'),
152
+ /** Category of file this template represents. */
153
+ kind: z.enum(TEMPLATE_KINDS),
154
+ /** Repo-relative path of the template source file. */
155
+ sourcePath: z.string().min(1, 'sourcePath must be non-empty'),
156
+ /** Project-relative path where the rendered template installs. */
157
+ installPath: z.string().min(1, 'installPath must be non-empty'),
158
+ /** Substitution strategy the installer applies to `sourcePath`. */
159
+ substitution: z.enum(TEMPLATE_SUBSTITUTIONS),
160
+ /** Declared placeholders this template requires. May be empty. */
161
+ placeholders: z.array(PlaceholderSpecSchema),
162
+ /** Reconciliation policy on upgrade. */
163
+ updateStrategy: z.enum(TEMPLATE_UPDATE_STRATEGIES),
164
+ });
165
+ //# sourceMappingURL=manifest.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"manifest.js","sourceRoot":"","sources":["../../src/templates/manifest.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,iFAAiF;AAEjF;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,CAAC,UAAU,EAAE,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,UAAU,EAAE,KAAK,CAAU,CAAC;AAKnG,iFAAiF;AAEjF;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG,CAAC,YAAY,EAAE,QAAQ,EAAE,YAAY,CAAU,CAAC;AAKtF,iFAAiF;AAEjF;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,0BAA0B,GAAG;IACxC,mBAAmB;IACnB,aAAa;IACb,WAAW;IACX,gBAAgB;CACR,CAAC;AAKX,iFAAiF;AAEjF;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG;IACjC,iBAAiB;IACjB,cAAc;IACd,cAAc;IACd,gBAAgB;IAChB,eAAe;IACf,SAAS;CACD,CAAC;AAKX;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,CAAC,CAAC,MAAM,CAAC;IAC5C;;;OAGG;IACH,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,EAAE,oCAAoC,CAAC;IAC7D,mEAAmE;IACnE,MAAM,EAAE,CAAC,CAAC,IAAI,CAAC,mBAAmB,CAAC;IACnC;;;;OAIG;IACH,UAAU,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,EAAE,0CAA0C,CAAC;IACzE;;;OAGG;IACH,YAAY,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,QAAQ,EAAE;CAClF,CAAC,CAAC;AAYH,iFAAiF;AAEjF;;;;;;;;;;;;;;GAcG;AACH,MAAM,CAAC,MAAM,2BAA2B,GAAG,CAAC,CAAC,MAAM,CAAC;IAClD,iDAAiD;IACjD,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,EAAE,sBAAsB,CAAC;IAC7C,iDAAiD;IACjD,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,cAAc,CAAC;IAC5B,sDAAsD;IACtD,UAAU,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,EAAE,8BAA8B,CAAC;IAC7D,kEAAkE;IAClE,WAAW,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,EAAE,+BAA+B,CAAC;IAC/D,mEAAmE;IACnE,YAAY,EAAE,CAAC,CAAC,IAAI,CAAC,sBAAsB,CAAC;IAC5C,kEAAkE;IAClE,YAAY,EAAE,CAAC,CAAC,KAAK,CAAC,qBAAqB,CAAC;IAC5C,wCAAwC;IACxC,cAAc,EAAE,CAAC,CAAC,IAAI,CAAC,0BAA0B,CAAC;CACnD,CAAC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cleocode/contracts",
3
- "version": "2026.5.112",
3
+ "version": "2026.5.113",
4
4
  "description": "Domain types, interfaces, and contracts for the CLEO ecosystem",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -138,7 +138,7 @@
138
138
  "dependencies": {
139
139
  "zod": "^4.3.6",
140
140
  "zod-to-json-schema": "^3.25.2",
141
- "@cleocode/lafs": "2026.5.112"
141
+ "@cleocode/lafs": "2026.5.113"
142
142
  },
143
143
  "scripts": {
144
144
  "build": "tsc -b --force && node scripts/copy-assets.mjs && node scripts/emit-schemas.mjs",
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Type-shape contract test for the envelope-wide `meta.suggestedNext`
3
+ * field promoted in T9920 (Saga T9855 / E8.1).
4
+ *
5
+ * `CliMeta.suggestedNext` is the canonical LLM-next-action hint
6
+ * surfaced on every CLI envelope. This test asserts the type allows
7
+ * the optional `ReadonlyArray<string>` shape and never widens to
8
+ * `string | null` or to nested objects (which would re-introduce the
9
+ * pre-T9920 nexus-internal-only structured form at the envelope tier).
10
+ *
11
+ * The runtime `attachSuggestedNext` helper lives in
12
+ * `packages/core/src/dispatch/suggested-next.ts` and is exercised by
13
+ * `packages/core/src/dispatch/__tests__/suggested-next.test.ts` —
14
+ * contracts cannot import core without breaking the layering contract.
15
+ *
16
+ * @epic T9919
17
+ * @task T9920
18
+ * @saga T9855
19
+ */
20
+
21
+ import type { CliEnvelope, CliMeta } from '@cleocode/lafs';
22
+ import { describe, expect, expectTypeOf, it } from 'vitest';
23
+
24
+ describe('CliMeta.suggestedNext type shape (T9920)', () => {
25
+ it('allows ReadonlyArray<string>', () => {
26
+ const meta: CliMeta = {
27
+ operation: 'tasks.show',
28
+ requestId: '00000000-0000-0000-0000-000000000000',
29
+ duration_ms: 0,
30
+ timestamp: '2026-05-24T00:00:00.000Z',
31
+ suggestedNext: ['cleo focus T1234', 'cleo verify T1234'],
32
+ };
33
+ expect(meta.suggestedNext).toEqual(['cleo focus T1234', 'cleo verify T1234']);
34
+ });
35
+
36
+ it('is optional — omitting it leaves a valid CliMeta', () => {
37
+ const meta: CliMeta = {
38
+ operation: 'tasks.list',
39
+ requestId: '00000000-0000-0000-0000-000000000001',
40
+ duration_ms: 0,
41
+ timestamp: '2026-05-24T00:00:00.000Z',
42
+ };
43
+ expect(meta.suggestedNext).toBeUndefined();
44
+ });
45
+
46
+ it('accepts an empty array (explicit "no follow-up suggested")', () => {
47
+ const meta: CliMeta = {
48
+ operation: 'tasks.show',
49
+ requestId: '00000000-0000-0000-0000-000000000002',
50
+ duration_ms: 0,
51
+ timestamp: '2026-05-24T00:00:00.000Z',
52
+ suggestedNext: [],
53
+ };
54
+ expect(meta.suggestedNext).toEqual([]);
55
+ expect(Array.isArray(meta.suggestedNext)).toBe(true);
56
+ });
57
+
58
+ it('typechecks as ReadonlyArray<string> — never `null`, never nested objects', () => {
59
+ expectTypeOf<CliMeta['suggestedNext']>().toEqualTypeOf<ReadonlyArray<string> | undefined>();
60
+ });
61
+
62
+ it('flows through CliEnvelope<T>.meta verbatim', () => {
63
+ const envelope: CliEnvelope<{ taskId: string }> = {
64
+ success: true,
65
+ data: { taskId: 'T1234' },
66
+ meta: {
67
+ operation: 'tasks.show',
68
+ requestId: '00000000-0000-0000-0000-000000000003',
69
+ duration_ms: 1,
70
+ timestamp: '2026-05-24T00:00:00.000Z',
71
+ suggestedNext: ['cleo focus T1234'],
72
+ },
73
+ };
74
+ expect(envelope.meta.suggestedNext).toEqual(['cleo focus T1234']);
75
+ });
76
+ });
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Tests for the ConfigManifest contract (T9876 / Saga T9855 / ADR-076).
3
+ *
4
+ * Covers:
5
+ * - Schema round-trip for each of the 4 built-in entries.
6
+ * - Merge-precedence invariant (project > global > defaults).
7
+ * - Metadata-scope isolation (NOT in the merge chain).
8
+ * - {@link DriftDetection} exhaustiveness via a `never` fallback switch.
9
+ *
10
+ * @task T9876
11
+ */
12
+
13
+ import { describe, expect, it } from 'vitest';
14
+ import {
15
+ CLEO_CONFIG_MANIFEST,
16
+ CONFIG_MANIFEST_ENTRIES,
17
+ type ConfigManifestEntry,
18
+ type ConfigScope,
19
+ configManifestEntrySchema,
20
+ type DriftDetection,
21
+ GLOBAL_CLEO_CONFIG_MANIFEST,
22
+ PROJECT_CONTEXT_MANIFEST,
23
+ PROJECT_INFO_MANIFEST,
24
+ } from '../manifest.js';
25
+
26
+ describe('ConfigManifest built-in entries', () => {
27
+ const builtins: ReadonlyArray<readonly [string, ConfigManifestEntry]> = [
28
+ ['PROJECT_INFO_MANIFEST', PROJECT_INFO_MANIFEST],
29
+ ['PROJECT_CONTEXT_MANIFEST', PROJECT_CONTEXT_MANIFEST],
30
+ ['CLEO_CONFIG_MANIFEST', CLEO_CONFIG_MANIFEST],
31
+ ['GLOBAL_CLEO_CONFIG_MANIFEST', GLOBAL_CLEO_CONFIG_MANIFEST],
32
+ ];
33
+
34
+ for (const [name, entry] of builtins) {
35
+ it(`${name} validates against configManifestEntrySchema`, () => {
36
+ const parsed = configManifestEntrySchema.parse(entry);
37
+ expect(parsed.id).toBe(entry.id);
38
+ expect(parsed.scope).toBe(entry.scope);
39
+ expect(parsed.path).toBe(entry.path);
40
+ expect(parsed.mergePrecedence).toBe(entry.mergePrecedence);
41
+ expect(parsed.driftDetection).toBe(entry.driftDetection);
42
+ });
43
+ }
44
+
45
+ it('CONFIG_MANIFEST_ENTRIES contains all 4 built-in entries', () => {
46
+ const ids = CONFIG_MANIFEST_ENTRIES.map((e) => e.id).sort();
47
+ expect(ids).toEqual([
48
+ 'cleo-config-global',
49
+ 'cleo-config-project',
50
+ 'project-context',
51
+ 'project-info',
52
+ ]);
53
+ });
54
+
55
+ it('CONFIG_MANIFEST_ENTRIES is frozen', () => {
56
+ expect(Object.isFrozen(CONFIG_MANIFEST_ENTRIES)).toBe(true);
57
+ });
58
+ });
59
+
60
+ describe('Merge-precedence invariant (project > global > defaults)', () => {
61
+ it('project precedence > global precedence (higher wins)', () => {
62
+ expect(CLEO_CONFIG_MANIFEST.mergePrecedence).toBeGreaterThan(
63
+ GLOBAL_CLEO_CONFIG_MANIFEST.mergePrecedence,
64
+ );
65
+ });
66
+
67
+ it('canonical precedence values: project=20, global=10, metadata=0', () => {
68
+ expect(CLEO_CONFIG_MANIFEST.mergePrecedence).toBe(20);
69
+ expect(GLOBAL_CLEO_CONFIG_MANIFEST.mergePrecedence).toBe(10);
70
+ expect(PROJECT_INFO_MANIFEST.mergePrecedence).toBe(0);
71
+ expect(PROJECT_CONTEXT_MANIFEST.mergePrecedence).toBe(0);
72
+ });
73
+
74
+ it('cascade-resolver sort order yields global then project (project wins)', () => {
75
+ const cascadeEntries = CONFIG_MANIFEST_ENTRIES.filter(
76
+ (e): e is ConfigManifestEntry & { scope: ConfigScope } =>
77
+ e.scope === 'global' || e.scope === 'project',
78
+ )
79
+ .slice() // copy before sort — array is frozen
80
+ .sort((a, b) => a.mergePrecedence - b.mergePrecedence);
81
+
82
+ expect(cascadeEntries.map((e) => e.scope)).toEqual(['global', 'project']);
83
+ });
84
+ });
85
+
86
+ describe('Metadata-scope isolation', () => {
87
+ it('metadata entries are NOT in the cascade merge chain', () => {
88
+ const cascadeScopes = CONFIG_MANIFEST_ENTRIES.filter((e) => e.scope !== 'metadata').map(
89
+ (e) => e.scope,
90
+ );
91
+ expect(cascadeScopes).toEqual(expect.arrayContaining(['global', 'project']));
92
+ expect(cascadeScopes).not.toContain('metadata');
93
+ });
94
+
95
+ it('all metadata entries have mergePrecedence 0 (separate channel)', () => {
96
+ const metadataEntries = CONFIG_MANIFEST_ENTRIES.filter((e) => e.scope === 'metadata');
97
+ expect(metadataEntries.length).toBeGreaterThan(0);
98
+ for (const e of metadataEntries) {
99
+ expect(e.mergePrecedence).toBe(0);
100
+ }
101
+ });
102
+ });
103
+
104
+ describe('DriftDetection exhaustiveness (type-level)', () => {
105
+ it('switch over DriftDetection covers all variants with never fallback', () => {
106
+ const describe_ = (d: DriftDetection): string => {
107
+ switch (d) {
108
+ case 'schema-validate':
109
+ return 'schema-validate';
110
+ case 'staleness-gate':
111
+ return 'staleness-gate';
112
+ case 'value-diff':
113
+ return 'value-diff';
114
+ case 'none':
115
+ return 'none';
116
+ default: {
117
+ // If a new variant is added without updating this switch, TS will
118
+ // refuse to assign it to `never` at compile time.
119
+ const _exhaustive: never = d;
120
+ return _exhaustive;
121
+ }
122
+ }
123
+ };
124
+
125
+ expect(describe_('schema-validate')).toBe('schema-validate');
126
+ expect(describe_('staleness-gate')).toBe('staleness-gate');
127
+ expect(describe_('value-diff')).toBe('value-diff');
128
+ expect(describe_('none')).toBe('none');
129
+ });
130
+ });
131
+
132
+ describe('configManifestEntrySchema rejects invalid input', () => {
133
+ it('rejects empty id', () => {
134
+ const result = configManifestEntrySchema.safeParse({
135
+ id: '',
136
+ scope: 'project',
137
+ path: '.cleo/config.json',
138
+ mergePrecedence: 20,
139
+ driftDetection: 'schema-validate',
140
+ });
141
+ expect(result.success).toBe(false);
142
+ });
143
+
144
+ it('rejects unknown scope', () => {
145
+ const result = configManifestEntrySchema.safeParse({
146
+ id: 'x',
147
+ scope: 'workspace',
148
+ path: '.cleo/x.json',
149
+ mergePrecedence: 0,
150
+ driftDetection: 'none',
151
+ });
152
+ expect(result.success).toBe(false);
153
+ });
154
+
155
+ it('rejects negative mergePrecedence', () => {
156
+ const result = configManifestEntrySchema.safeParse({
157
+ id: 'x',
158
+ scope: 'project',
159
+ path: '.cleo/x.json',
160
+ mergePrecedence: -1,
161
+ driftDetection: 'none',
162
+ });
163
+ expect(result.success).toBe(false);
164
+ });
165
+
166
+ it('rejects unknown driftDetection variant', () => {
167
+ const result = configManifestEntrySchema.safeParse({
168
+ id: 'x',
169
+ scope: 'project',
170
+ path: '.cleo/x.json',
171
+ mergePrecedence: 0,
172
+ driftDetection: 'sha-compare',
173
+ });
174
+ expect(result.success).toBe(false);
175
+ });
176
+ });
@@ -0,0 +1,218 @@
1
+ /**
2
+ * ConfigManifest contract — declarative registry of every config-like file CLEO
3
+ * reads, writes, or merges, with its scope, schema, merge precedence, and the
4
+ * drift-detection strategy applied to it.
5
+ *
6
+ * Foundation for the CORE SSoT config registry (T9878) and the `resolveCleoConfig`
7
+ * cascade resolver. Built-in entries cover the four canonical CLEO files:
8
+ *
9
+ * - `~/.cleo/config.json` (`global`, precedence 10)
10
+ * - `<project>/.cleo/config.json` (`project`, precedence 20)
11
+ * - `<project>/.cleo/project-info.json` (`metadata`, separate channel)
12
+ * - `<project>/.cleo/project-context.json` (`metadata`, separate channel)
13
+ *
14
+ * Merge semantics: higher `mergePrecedence` wins. The `metadata` scope is
15
+ * intentionally OUTSIDE the project ↔ global merge chain — those files are
16
+ * read-only consumer state and must NEVER be merged into resolved CleoConfig.
17
+ *
18
+ * @task T9876
19
+ * @saga T9855
20
+ * @adr 076
21
+ */
22
+
23
+ import { type ZodTypeAny, z } from 'zod';
24
+
25
+ // ───────────────────────────────────────────────────────────────────────────
26
+ // Scope + drift unions
27
+ // ───────────────────────────────────────────────────────────────────────────
28
+
29
+ /**
30
+ * Cascade-participating scopes for the project ↔ global merge chain.
31
+ *
32
+ * - `'global'` — `~/.cleo/config.json` (precedence 10)
33
+ * - `'project'` — `<project>/.cleo/config.json` (precedence 20, higher wins)
34
+ *
35
+ * Note: `'metadata'` is NOT part of this union — metadata-scoped files are
36
+ * read-only consumer state and live on a separate channel. See
37
+ * {@link ConfigManifestScope} for the full union including metadata.
38
+ *
39
+ * @task T9876
40
+ */
41
+ export type ConfigScope = 'global' | 'project';
42
+
43
+ /**
44
+ * Full scope union covering cascade scopes AND the read-only `metadata`
45
+ * channel used by `project-info.json` and `project-context.json`.
46
+ *
47
+ * Metadata-scoped entries are NEVER merged into the resolved CleoConfig —
48
+ * they are surfaced separately as consumer state and validated only for
49
+ * schema conformance / staleness.
50
+ *
51
+ * @task T9876
52
+ */
53
+ export type ConfigManifestScope = ConfigScope | 'metadata';
54
+
55
+ /**
56
+ * Drift-detection strategy applied to a manifest entry.
57
+ *
58
+ * - `'schema-validate'` — Re-parse the file's contents against {@link ConfigManifestEntry.schema}
59
+ * on read. Any parse error is reported as drift.
60
+ * - `'staleness-gate'` — Compare the file's `detectedAt` (or equivalent freshness key)
61
+ * against a freshness threshold. Stale files are flagged but not rejected.
62
+ * - `'value-diff'` — Diff the file's resolved values against the manifest's
63
+ * `defaults` baseline and report changed keys. Useful for invariant tracking.
64
+ * - `'none'` — No drift detection. Entry is informational-only.
65
+ *
66
+ * @task T9876
67
+ */
68
+ export type DriftDetection = 'schema-validate' | 'staleness-gate' | 'value-diff' | 'none';
69
+
70
+ // ───────────────────────────────────────────────────────────────────────────
71
+ // Manifest entry shape
72
+ // ───────────────────────────────────────────────────────────────────────────
73
+
74
+ /**
75
+ * A single registry entry describing one config-like file CLEO is aware of.
76
+ *
77
+ * `mergePrecedence` orders entries within the SAME scope channel. Across
78
+ * channels, the cascade-resolver consumes entries with `scope` in
79
+ * {@link ConfigScope} (global/project) and merges by ascending precedence
80
+ * (higher wins). Metadata entries are surfaced separately.
81
+ *
82
+ * @task T9876
83
+ */
84
+ export interface ConfigManifestEntry {
85
+ /** Stable identifier for the entry (e.g. `'cleo-config-project'`). */
86
+ id: string;
87
+ /** Where the file lives in the precedence hierarchy. */
88
+ scope: ConfigManifestScope;
89
+ /** Absolute or `~`-rooted path to the file on disk. */
90
+ path: string;
91
+ /**
92
+ * Optional Zod schema used by the `'schema-validate'` drift detector to
93
+ * re-parse the file's contents. `null` disables schema validation even when
94
+ * `driftDetection === 'schema-validate'` (advisory-only mode).
95
+ */
96
+ schema?: ZodTypeAny | null;
97
+ /**
98
+ * Numeric merge precedence within the cascade. Higher wins.
99
+ *
100
+ * Canonical values:
101
+ * - `0` — defaults / metadata (NOT part of the merge chain)
102
+ * - `10` — global
103
+ * - `20` — project
104
+ */
105
+ mergePrecedence: number;
106
+ /** Drift-detection strategy to apply to this entry. */
107
+ driftDetection: DriftDetection;
108
+ /** Optional default values used as the baseline for `'value-diff'` drift detection. */
109
+ defaults?: Record<string, unknown>;
110
+ }
111
+
112
+ // ───────────────────────────────────────────────────────────────────────────
113
+ // Built-in manifest entries (4 canonical CLEO files)
114
+ // ───────────────────────────────────────────────────────────────────────────
115
+
116
+ /**
117
+ * Manifest entry for `<project>/.cleo/project-info.json`.
118
+ *
119
+ * Metadata channel (NOT part of the merge cascade). Schema-validated on
120
+ * read because the file is consumer state with a stable shape.
121
+ *
122
+ * @task T9876
123
+ */
124
+ export const PROJECT_INFO_MANIFEST: ConfigManifestEntry = {
125
+ id: 'project-info',
126
+ scope: 'metadata',
127
+ path: '.cleo/project-info.json',
128
+ mergePrecedence: 0,
129
+ driftDetection: 'schema-validate',
130
+ };
131
+
132
+ /**
133
+ * Manifest entry for `<project>/.cleo/project-context.json`.
134
+ *
135
+ * Metadata channel. Staleness-gated because the file is regenerated by
136
+ * project-detection scans and consumers care about freshness, not exact shape.
137
+ *
138
+ * @task T9876
139
+ */
140
+ export const PROJECT_CONTEXT_MANIFEST: ConfigManifestEntry = {
141
+ id: 'project-context',
142
+ scope: 'metadata',
143
+ path: '.cleo/project-context.json',
144
+ mergePrecedence: 0,
145
+ driftDetection: 'staleness-gate',
146
+ };
147
+
148
+ /**
149
+ * Manifest entry for `<project>/.cleo/config.json` (project-scoped CleoConfig).
150
+ *
151
+ * Project scope; precedence 20 — wins over global. Schema-validated.
152
+ *
153
+ * @task T9876
154
+ */
155
+ export const CLEO_CONFIG_MANIFEST: ConfigManifestEntry = {
156
+ id: 'cleo-config-project',
157
+ scope: 'project',
158
+ path: '.cleo/config.json',
159
+ mergePrecedence: 20,
160
+ driftDetection: 'schema-validate',
161
+ };
162
+
163
+ /**
164
+ * Manifest entry for `~/.cleo/config.json` (global CleoConfig).
165
+ *
166
+ * Global scope; precedence 10 — loses to project. Schema-validated.
167
+ *
168
+ * @task T9876
169
+ */
170
+ export const GLOBAL_CLEO_CONFIG_MANIFEST: ConfigManifestEntry = {
171
+ id: 'cleo-config-global',
172
+ scope: 'global',
173
+ path: '~/.cleo/config.json',
174
+ mergePrecedence: 10,
175
+ driftDetection: 'schema-validate',
176
+ };
177
+
178
+ /**
179
+ * Frozen registry of all built-in manifest entries shipped with the contracts
180
+ * package. Order is informational only — consumers MUST sort by
181
+ * `mergePrecedence` when applying the cascade.
182
+ *
183
+ * @task T9876
184
+ */
185
+ export const CONFIG_MANIFEST_ENTRIES: readonly ConfigManifestEntry[] = Object.freeze([
186
+ PROJECT_INFO_MANIFEST,
187
+ PROJECT_CONTEXT_MANIFEST,
188
+ GLOBAL_CLEO_CONFIG_MANIFEST,
189
+ CLEO_CONFIG_MANIFEST,
190
+ ]);
191
+
192
+ // ───────────────────────────────────────────────────────────────────────────
193
+ // Runtime validation schema
194
+ // ───────────────────────────────────────────────────────────────────────────
195
+
196
+ /**
197
+ * Zod schema for {@link ConfigManifestEntry}.
198
+ *
199
+ * Treats `schema` as `unknown` because `ZodTypeAny` instances cannot be
200
+ * validated through Zod itself without infinite recursion — schema-shape
201
+ * checking is the responsibility of the consumer that constructs the entry.
202
+ *
203
+ * @task T9876
204
+ */
205
+ export const configManifestEntrySchema = z.object({
206
+ id: z.string().min(1),
207
+ scope: z.union([z.literal('global'), z.literal('project'), z.literal('metadata')]),
208
+ path: z.string().min(1),
209
+ schema: z.unknown().optional(),
210
+ mergePrecedence: z.number().int().nonnegative(),
211
+ driftDetection: z.union([
212
+ z.literal('schema-validate'),
213
+ z.literal('staleness-gate'),
214
+ z.literal('value-diff'),
215
+ z.literal('none'),
216
+ ]),
217
+ defaults: z.record(z.string(), z.unknown()).optional(),
218
+ });