@descix/cli 1.0.1 → 1.0.4

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 (100) hide show
  1. package/LICENSE.md +671 -0
  2. package/README.md +43 -4
  3. package/agent-assets/guides/README.md +2 -2
  4. package/agent-assets/guides/dynamic-site-pattern.md +29 -14
  5. package/agent-assets/guides/knowledge-only-pattern.md +5 -5
  6. package/agent-assets/guides/microservice-pattern.md +48 -4
  7. package/agent-assets/guides/static-site-pattern.md +8 -0
  8. package/agent-assets/instructions/app-asset-validation.md +5 -5
  9. package/agent-assets/instructions/onboarding-flow.md +4 -4
  10. package/agent-assets/instructions/standard-folder-structure.md +20 -11
  11. package/agent-assets/reference/app-creation.md +42 -20
  12. package/agent-assets/reference/architecture.md +24 -22
  13. package/agent-assets/reference/local-dev.md +230 -67
  14. package/agent-assets/reference/mcp-integration.md +21 -15
  15. package/agent-assets/reference/platform-api-consumer.md +244 -0
  16. package/agent-assets/reference/workspace-config.md +7 -7
  17. package/agent-assets/rules/descix_mcp.template.mdc +8 -8
  18. package/bin/descix.js +2559 -1435
  19. package/bin/kb-deny-lint.mjs +91 -0
  20. package/bin/mcp-server.js +175 -91
  21. package/lib/agent-files.js +37 -25
  22. package/lib/api-client.js +223 -56
  23. package/lib/auth-guard.js +13 -3
  24. package/lib/chat-session.js +187 -0
  25. package/lib/cli-version.js +59 -0
  26. package/lib/commands/airdrop.js +251 -0
  27. package/lib/commands/auth.js +254 -72
  28. package/lib/commands/briefer/errors.js +88 -0
  29. package/lib/commands/briefer/index.js +319 -0
  30. package/lib/commands/briefer/sources/canonical-sources.js +129 -0
  31. package/lib/commands/briefer/sources/entitlements.js +220 -0
  32. package/lib/commands/briefer/sources/environments.js +205 -0
  33. package/lib/commands/briefer/sources/identifiers.js +214 -0
  34. package/lib/commands/briefer/sources/microservice-deploy.js +267 -0
  35. package/lib/commands/briefer/sources/routing.js +275 -0
  36. package/lib/commands/briefer/sources/what-is-not.js +223 -0
  37. package/lib/commands/briefer/stitcher.js +134 -0
  38. package/lib/commands/briefer/util/source-reader.js +502 -0
  39. package/lib/commands/config.js +210 -90
  40. package/lib/commands/corpus.js +978 -0
  41. package/lib/commands/credits.js +122 -0
  42. package/lib/commands/health.js +627 -0
  43. package/lib/commands/kb-list-render.js +32 -0
  44. package/lib/commands/kb.js +190 -413
  45. package/lib/commands/mcp.js +5 -5
  46. package/lib/commands/microservice-restart.js +359 -0
  47. package/lib/commands/model-config.js +313 -0
  48. package/lib/commands/retired-kb-sync.js +134 -0
  49. package/lib/commands/serve.js +26 -1
  50. package/lib/commands/status.js +23 -51
  51. package/lib/commands/update.js +36 -107
  52. package/lib/core/Chunker.js +491 -17
  53. package/lib/core/CorpusDenyLint.js +326 -0
  54. package/lib/core/CorpusWalker.js +357 -0
  55. package/lib/core/Hydrator.js +75 -29
  56. package/lib/core/ManifestLoader.js +533 -0
  57. package/lib/core/SiteWalker.js +218 -0
  58. package/lib/core/Syncer.js +163 -11
  59. package/lib/diagnostic-log.js +41 -0
  60. package/lib/environment-report.js +100 -0
  61. package/lib/global-config.js +4 -1
  62. package/lib/oauth-client.js +164 -0
  63. package/lib/origin.js +143 -0
  64. package/lib/service-api-client.js +95 -0
  65. package/lib/wallet-environment.js +49 -0
  66. package/lib/wallet-file.js +16 -2
  67. package/lib/wizard/setup.js +10 -9
  68. package/lib/workspace-config.js +380 -235
  69. package/lib/workspace-identity.js +92 -0
  70. package/lib/workspace-utils.js +41 -61
  71. package/package.json +19 -6
  72. package/templates/agent-claude.md +157 -9
  73. package/templates/agent-cline.md +68 -8
  74. package/templates/agent-copilot.md +68 -8
  75. package/templates/agent-cursor.md +68 -8
  76. package/templates/alignment-schema.json +20 -0
  77. package/templates/cloud-brain-hook.sh +20 -0
  78. package/templates/drive/README.md +1 -1
  79. package/templates/drive_templates/README.md +2 -2
  80. package/templates/scaffolds/README.md +6 -10
  81. package/templates/scaffolds/microservice/SERVICE_README_sdk.md +104 -149
  82. package/templates/scaffolds/microservice/app.js +51 -10
  83. package/templates/scaffolds/microservice/app.yaml +2 -2
  84. package/templates/scaffolds/microservice/defaults-config-dev.json +4 -0
  85. package/templates/scaffolds/microservice/dev-overrides.example.json +5 -0
  86. package/templates/scaffolds/microservice/manifest.json +0 -1
  87. package/templates/scaffolds/microservice/package.json +3 -3
  88. package/templates/scaffolds/microservice/scripts/register.js +56 -41
  89. package/templates/scaffolds/microservice/services/mcpClient.js +55 -65
  90. package/templates/scaffolds/microservice/services/utils.js +20 -238
  91. package/templates/scaffolds/microservice/templates/SERVICE_README_TEMPLATE.md +24 -0
  92. package/lib/commands/app-wizard.js +0 -627
  93. package/lib/commands/folder.js +0 -291
  94. package/lib/core/PathContext.js +0 -335
  95. package/templates/scaffolds/microservice/dev-overrides.json.example +0 -5
  96. package/templates/scaffolds/site/DeSciXAppSDK.js +0 -62
  97. package/templates/scaffolds/site/README.md +0 -51
  98. package/templates/scaffolds/site/app.js +0 -22
  99. package/templates/scaffolds/site/index.html +0 -42
  100. package/templates/scaffolds/site/styles.css +0 -123
@@ -16,6 +16,38 @@
16
16
  import * as fs from 'fs/promises';
17
17
  import * as path from 'path';
18
18
  import { fileURLToPath } from 'url';
19
+
20
+ import { SITE_SCAFFOLD_DIR } from '@descix/app-sdk/scaffold';
21
+
22
+ /**
23
+ * ONE OWNER for scaffold path resolution. Hydrator.js lives at descix-cli/lib/core/, so two
24
+ * '..' walks reach the CLI package root. Two functions used to resolve this independently and
25
+ * they DISAGREED — copyScaffold walked two, getAvailableScaffolds walked four (and used a
26
+ * hand-rolled import.meta.url.replace('file://','') instead of fileURLToPath). The four-walk
27
+ * path does not exist, so getAvailableScaffolds returned [] on every call, and its bare catch
28
+ * made a path bug indistinguishable from "no scaffolds are installed".
29
+ *
30
+ * The SITE scaffold is not one of ours: it is app-side browser code whose generated
31
+ * `DeSciXAppSDK.js` is produced from @descix/app-sdk's template + bridgeResolver, so that
32
+ * package OWNS the directory and exports its location. The CLI consumes the constant rather
33
+ * than re-spelling the path, which is what lets `@descix/sdk/app`'s bin scaffold the very same
34
+ * bytes without a second copy existing anywhere.
35
+ */
36
+ const CLI_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
37
+
38
+ /**
39
+ * The scaffold registry — ONE owner of BOTH "which scaffold types exist" and "where each one
40
+ * lives". These used to be two facts derived in two places: a hand-kept `validTypes` array
41
+ * inside copyScaffold, and a readdir of the templates directory inside getAvailableScaffolds.
42
+ * Two derivations of one fact disagree silently, and this pair was primed to: moving `site`
43
+ * out of the CLI would have left `validTypes` still advertising it while the readdir stopped
44
+ * reporting it, and the error a developer saw would have named a missing directory rather
45
+ * than the move.
46
+ */
47
+ const SCAFFOLD_DIRS = {
48
+ site: SITE_SCAFFOLD_DIR,
49
+ microservice: path.join(CLI_ROOT, 'templates', 'scaffolds', 'microservice'),
50
+ };
19
51
  import { google } from 'googleapis';
20
52
  import * as driveADC from '../google-storage-adc.js';
21
53
 
@@ -254,27 +286,35 @@ async function pullFolder(localPath, driveFolderId, options = {}) {
254
286
  * @returns {Promise<{pulled: number, converted: number, skipped: number, unchanged: number}>}
255
287
  */
256
288
  export async function hydrateKb(config, options = {}) {
257
- const { workspaceRoot, communityId, appId, kbId = 'General', baseFolderId, localPath } = config;
258
- const {
259
- verbose = false,
289
+ const { workspaceRoot, communityId, appId, kbId = 'General', baseFolderId, directFolderId, localPath } = config;
290
+ const {
291
+ verbose = false,
260
292
  mergeMode = 'merge',
261
293
  skipUnchanged = true,
262
294
  dryRun = false,
263
295
  onProgress = null
264
296
  } = options;
265
-
297
+
266
298
  // Verify ADC auth
267
299
  await driveADC.verifyDriveAuth();
268
-
269
- // Navigate to KB folder using template path
270
- const kbPath = `${communityId}/${appId}/kb/${kbId}`;
271
- const kbDriveFolderId = await driveADC.findFolderByPath(baseFolderId, kbPath);
272
-
273
- if (!kbDriveFolderId) {
274
- throw new Error(
275
- `KB folder not found at path: ${kbPath}\n` +
276
- 'Ensure the app was created from template and the KB folder exists in Drive.'
277
- );
300
+
301
+ // Resolve the KB Drive folder: direct override or navigate via base_folder_id
302
+ let kbDriveFolderId;
303
+ if (directFolderId) {
304
+ // --folder override: use the provided folder ID directly (arbitrary Drive folder)
305
+ kbDriveFolderId = directFolderId;
306
+ if (verbose) console.log(` Using override folder: ${directFolderId}`);
307
+ } else {
308
+ // Standard path: navigate to KB folder using template path
309
+ const kbPath = `${communityId}/${appId}/kb/${kbId}`;
310
+ kbDriveFolderId = await driveADC.findFolderByPath(baseFolderId, kbPath);
311
+
312
+ if (!kbDriveFolderId) {
313
+ throw new Error(
314
+ `KB folder not found at path: ${kbPath}\n` +
315
+ 'Ensure the app was created from template and the KB folder exists in Drive.'
316
+ );
317
+ }
278
318
  }
279
319
 
280
320
  // Prepare local directories with new structure
@@ -692,15 +732,13 @@ export async function checkStagingFiles(stagingDir) {
692
732
  export async function copyScaffold(scaffoldType, appPath, options = {}) {
693
733
  const { verbose = false, force = false } = options;
694
734
 
695
- const validTypes = ['site', 'microservice'];
696
- if (!validTypes.includes(scaffoldType)) {
697
- throw new Error(`Invalid scaffold type: ${scaffoldType}. Valid types: ${validTypes.join(', ')}`);
735
+ const scaffoldDir = SCAFFOLD_DIRS[scaffoldType];
736
+ if (!scaffoldDir) {
737
+ throw new Error(
738
+ `Invalid scaffold type: ${scaffoldType}. Valid types: ${Object.keys(SCAFFOLD_DIRS).join(', ')}`
739
+ );
698
740
  }
699
741
 
700
- // Find scaffold template directory (templates live inside CLI package)
701
- const cliRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
702
- const scaffoldDir = path.join(cliRoot, 'templates', 'scaffolds', scaffoldType);
703
-
704
742
  // Check if scaffold exists
705
743
  try {
706
744
  await fs.access(scaffoldDir);
@@ -773,15 +811,23 @@ async function copyDir(src, dest, stats, verbose) {
773
811
  * @returns {Promise<string[]>}
774
812
  */
775
813
  export async function getAvailableScaffolds() {
776
- const sdkRoot = path.resolve(path.dirname(import.meta.url.replace('file://', '')), '..', '..', '..', '..');
777
- const scaffoldsDir = path.join(sdkRoot, 'templates', 'scaffolds');
778
-
779
- try {
780
- const entries = await fs.readdir(scaffoldsDir, { withFileTypes: true });
781
- return entries.filter(e => e.isDirectory()).map(e => e.name);
782
- } catch {
783
- return [];
814
+ // FAIL LOUD per entry: an unreadable scaffold directory in a shipped CLI is a packaging
815
+ // failure, not "no scaffolds". Swallowing it to [] is what hid the original path bug — the
816
+ // caller could not tell a broken install from an empty one. Name the type, the owning
817
+ // package and the underlying error, because `site` now ships from a DIFFERENT package than
818
+ // `microservice` and "which package is broken" is the first thing you need to know.
819
+ for (const [type, dir] of Object.entries(SCAFFOLD_DIRS)) {
820
+ try {
821
+ await fs.access(dir);
822
+ } catch (err) {
823
+ throw new Error(
824
+ `Scaffold templates for "${type}" are unreadable at ${dir} (${err.code || err.message}). ` +
825
+ `This is a broken installation of ` +
826
+ `${type === 'site' ? '@descix/app-sdk' : '@descix/cli'}, not an empty template set.`
827
+ );
828
+ }
784
829
  }
830
+ return Object.keys(SCAFFOLD_DIRS);
785
831
  }
786
832
 
787
833
  // Named exports for functions not already exported inline
@@ -0,0 +1,533 @@
1
+ /**
2
+ * ManifestLoader - Load and validate corpus sync manifests
3
+ *
4
+ * Manifests declare a *view* over git-managed files for RAG indexing.
5
+ * Files stay in place — no staging. Git blob SHAs handle versioning.
6
+ * Pinecone metadata handles multi-tenancy (CEO-D12).
7
+ *
8
+ * Manifest location convention:
9
+ * {app_root}/.descix/manifests/{kb_name}.json
10
+ *
11
+ * workspace.json `localPath` provides the app root.
12
+ * The CLI discovers manifests at `{localPath}/.descix/manifests/`.
13
+ */
14
+
15
+ import * as fs from 'fs/promises';
16
+ import * as path from 'path';
17
+ import { execFileSync } from 'child_process';
18
+ // Canonical doc_class taxonomy lives in CorpusDenyLint — import it, never re-list.
19
+ import { PUBLISHABLE_DOC_CLASSES, DENY_DOC_CLASSES } from './CorpusDenyLint.js';
20
+
21
+ // Valid publish tiers (model §1). "I" internal (default), "P" public, "U" user.
22
+ const PUBLISH_TIERS = new Set(['P', 'I', 'U']);
23
+
24
+ // ============ Canonical Manifest Schema (WS-EVP-DESCIX-KB-CLEANUP item 5) ============
25
+ //
26
+ // ONE canonical corpus-manifest schema. Historically two families shared
27
+ // `.descix/manifests/*.json` with drifting per-source field names — the DeSciX-family
28
+ // (`tier`/`doc_type`/`syncignore`/`raw_path`) and the EGPT-research family (which also used
29
+ // `src_path`). Because every field except `path` was optional, the loader SILENTLY accepted both
30
+ // AND silently ignored any unrecognized/misspelled field (the same fail-loud gap as the KB command
31
+ // params — a typo'd `synchignore` produced an empty syncignore with no signal). This is the single
32
+ // source of truth for the recognized field set. Consumers (the walker, the deny-lint, a conformance
33
+ // test) key off these exports so the schema can never drift by hand again.
34
+ //
35
+ // Deprecation window: unrecognized fields and deprecated aliases WARN (they do not throw) so both
36
+ // legacy formats keep syncing. `src_path` is normalized to its canonical name `raw_path` at load.
37
+ // The schema doc lives at V2_docs/architecture/corpus-manifest-schema.md.
38
+ export const CANONICAL_SOURCE_FIELDS = new Set([
39
+ 'path', 'repo', 'ref', 'tier', 'doc_type', 'syncignore',
40
+ 'doc_class', 'license_basis', 'lint_exempt', 'exempt_reason', 'raw_path',
41
+ ]);
42
+
43
+ /**
44
+ * The agreed shape of a manifest source (interface-ws-c4-I2-manifest-source), stated ONCE.
45
+ * Refusal messages quote from it, so the contract and its enforcement cannot drift apart.
46
+ */
47
+ export const SOURCE_SHAPE_CONTRACT =
48
+ "A manifest source names WHERE its content comes from, and is synced by fetching that ref - " +
49
+ "never by reading an adjacent working tree. A source is exactly one of two shapes. IN-REPO: " +
50
+ "omit `repo`; `path` is repo-root-relative within the manifest's OWN repository, fetched at " +
51
+ "`ref`. CROSS-REPO: `repo` is REQUIRED and is an owner/name slug (e.g. 'eabadir/unk'), never a " +
52
+ "URL; `path` is repo-root-relative within that repository, fetched at `ref`. Naming your OWN " +
53
+ "repository explicitly in `repo` is REFUSED - omission is the single spelling of 'here', an " +
54
+ "explicit slug the single spelling of 'elsewhere'. A `path` beginning with '../' is REFUSED " +
55
+ "naming the migration: adjacency reads a working tree, so the source's `ref` is not honored and " +
56
+ "the provenance it claims is false. `ref` may be a branch, tag or sha; a mutable branch ref is " +
57
+ "legal, and sync-state records the RESOLVED COMMIT SHA actually synced, so provenance is exact " +
58
+ "even when the ref moves. `path` is a literal - no globs; `syncignore` already owns exclusion.";
59
+
60
+ /** The ref a source is walked at when it names none. ONE owner — consumers import it. */
61
+ export const DEFAULT_SOURCE_REF = 'main';
62
+
63
+ // Glob metacharacters. `path` is a LITERAL — `syncignore` already owns exclusion, so a glob in
64
+ // `path` is a second, silently-diverging spelling of the same intent.
65
+ const GLOB_CHARS = /[*?[\]{}]/;
66
+
67
+ /**
68
+ * Normalize any git remote URL spelling to ONE canonical `owner/name` slug.
69
+ *
70
+ * The single owner of "which repository is this". Every caller derives from here — deriving the
71
+ * slug a second way is how the self-naming refusal would silently start disagreeing with itself.
72
+ *
73
+ * Handles: https://host/owner/name(.git), git@host:owner/name(.git), ssh://git@host/owner/name(.git)
74
+ *
75
+ * @param {string|null|undefined} url - a git remote URL
76
+ * @returns {string|null} `owner/name`, or null when no slug can be read
77
+ */
78
+ export function repoSlugFromRemoteUrl(url) {
79
+ if (!url || typeof url !== 'string') return null;
80
+ let s = url.trim();
81
+ if (!s) return null;
82
+ s = s.replace(/\.git$/, '');
83
+ // scp-style: git@host:owner/name
84
+ const scp = s.match(/^[^/]+@[^:/]+:(.+)$/);
85
+ if (scp) s = scp[1];
86
+ else {
87
+ // URL-style: scheme://[user@]host/owner/name
88
+ const u = s.match(/^[a-z][a-z0-9+.-]*:\/\/[^/]*\/(.+)$/i);
89
+ if (u) s = u[1];
90
+ }
91
+ const parts = s.split('/').filter(Boolean);
92
+ if (parts.length < 2) return null;
93
+ return `${parts[parts.length - 2]}/${parts[parts.length - 1]}`;
94
+ }
95
+
96
+ /**
97
+ * Read the `owner/name` slug of the repository that physically CONTAINS a directory.
98
+ *
99
+ * @param {string} dir - a directory inside the repository
100
+ * @returns {string|null} the slug, or null when there is no repo / no `origin` remote
101
+ */
102
+ export function detectRepoSlug(dir) {
103
+ try {
104
+ const url = execFileSync('git', ['remote', 'get-url', 'origin'], {
105
+ cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
106
+ }).trim();
107
+ return repoSlugFromRemoteUrl(url);
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Enforce the two-shape source contract on ONE source entry.
115
+ *
116
+ * @param {Object} src - raw source entry
117
+ * @param {number} i - index (for message context)
118
+ * @param {string} filePath - manifest path (for message context)
119
+ * @param {string|null} ownRepoSlug - slug of the repository containing the manifest
120
+ * @throws {Error} naming the offending value and the canonical spelling
121
+ */
122
+ function assertSourceShape(src, i, filePath, ownRepoSlug) {
123
+ const at = `Invalid manifest at ${filePath}: sources[${i}] (${src.path})`;
124
+
125
+ // ── `path` must be a literal, repo-root-relative path — never adjacency, never a glob.
126
+ const segments = String(src.path).split('/');
127
+ if (String(src.path).startsWith('../') || segments.includes('..')) {
128
+ throw new Error(
129
+ `${at}: a path beginning with '../' is REFUSED. Adjacency reads a working tree, so the ` +
130
+ `source's ref "${src.ref ?? DEFAULT_SOURCE_REF}" is NOT honored and the provenance it claims is false. ` +
131
+ `MIGRATE to the cross-repo shape: set \`repo\` to the owner/name slug of the repository that ` +
132
+ `owns this content and make \`path\` repo-root-relative within it ` +
133
+ `(e.g. { "repo": "eabadir/unk", "path": "unkamon-beast/README.md", "ref": "main" }).`
134
+ );
135
+ }
136
+ if (GLOB_CHARS.test(String(src.path))) {
137
+ throw new Error(
138
+ `${at}: \`path\` is a literal — globs are REFUSED. \`syncignore\` already owns exclusion; ` +
139
+ `name the directory literally and express the filter in \`syncignore\`.`
140
+ );
141
+ }
142
+
143
+ if (src.repo === undefined) return; // IN-REPO shape: omission is the single spelling of "here".
144
+
145
+ // ── CROSS-REPO shape.
146
+ if (typeof src.repo !== 'string' || !src.repo.trim()) {
147
+ throw new Error(`${at}: \`repo\` must be an owner/name slug string.`);
148
+ }
149
+ const repo = src.repo.trim();
150
+ const looksLikeUrl = /:\/\//.test(repo) || /^[^/]+@/.test(repo) || /\.git$/.test(repo);
151
+ if (looksLikeUrl || repo.split('/').filter(Boolean).length !== 2 || repo.startsWith('/') || repo.endsWith('/')) {
152
+ const suggestion = repoSlugFromRemoteUrl(repo);
153
+ throw new Error(
154
+ `${at}: \`repo\` must be an owner/name slug (e.g. 'eabadir/unk'), never a URL — got "${repo}"` +
155
+ `${suggestion ? `. Did you mean "${suggestion}"?` : '.'}`
156
+ );
157
+ }
158
+ if (ownRepoSlug && repo.toLowerCase() === ownRepoSlug.toLowerCase()) {
159
+ throw new Error(
160
+ `${at}: \`repo\` names this manifest's OWN repository ("${repo}") — REFUSED. Omission is the ` +
161
+ `single spelling of 'here' and an explicit slug the single spelling of 'elsewhere'; two ` +
162
+ `spellings of 'here' is exactly the drift this field exists to prevent. Remove \`repo\` and ` +
163
+ `keep \`path\` relative to this repository.`
164
+ );
165
+ }
166
+ if (!ownRepoSlug) {
167
+ throw new Error(
168
+ `${at}: \`repo\` is set ("${repo}") but this manifest's OWN repository slug could not be ` +
169
+ `determined (no git repository or no 'origin' remote at ${path.dirname(filePath)}), so the ` +
170
+ `"names its own repository" check CANNOT run. Refusing rather than accepting an unverifiable ` +
171
+ `cross-repo source.`
172
+ );
173
+ }
174
+ }
175
+
176
+ // Deprecated per-source field -> canonical replacement. Normalized at load (canonical wins if both
177
+ // are present); a deprecation warning is emitted during the migration window.
178
+ export const DEPRECATED_SOURCE_ALIASES = Object.freeze({ src_path: 'raw_path' });
179
+
180
+ // Recognized manifest-level (top-level) keys. `_`-prefixed keys are annotation/comment keys and are
181
+ // always allowed (e.g. `_purpose`). Unknown top-level keys warn like unknown source fields.
182
+ export const CANONICAL_MANIFEST_FIELDS = new Set([
183
+ 'kb_name', 'sync_mode', 'publish_tier', 'sources', 'github',
184
+ ]);
185
+
186
+ /**
187
+ * Collect schema warnings for a manifest WITHOUT throwing — the validator half of the canonical
188
+ * schema. Flags unknown per-source/top-level fields (typo guard) and deprecated aliases. Exported
189
+ * so a conformance test drives off the SAME field set the loader enforces (no hand-mirrored list).
190
+ *
191
+ * @param {Object} manifest - parsed manifest object
192
+ * @param {string} filePath - manifest path (for message context)
193
+ * @returns {string[]} human-readable warning lines (empty when the manifest is fully canonical)
194
+ */
195
+ export function collectManifestSchemaWarnings(manifest, filePath) {
196
+ const warnings = [];
197
+ const base = path.basename(filePath || 'manifest.json');
198
+ const recognized = [...CANONICAL_SOURCE_FIELDS].join(', ');
199
+
200
+ for (const key of Object.keys(manifest || {})) {
201
+ if (key.startsWith('_')) continue; // annotation/comment key
202
+ if (CANONICAL_MANIFEST_FIELDS.has(key)) continue;
203
+ warnings.push(` ⚠ ${base}: unknown top-level field "${key}" is ignored — check for a typo.`);
204
+ }
205
+
206
+ const sources = Array.isArray(manifest?.sources) ? manifest.sources : [];
207
+ sources.forEach((src, i) => {
208
+ for (const key of Object.keys(src || {})) {
209
+ if (key.startsWith('_')) continue;
210
+ if (CANONICAL_SOURCE_FIELDS.has(key)) continue;
211
+ if (key in DEPRECATED_SOURCE_ALIASES) {
212
+ warnings.push(
213
+ ` ⚠ ${base} sources[${i}] (${src.path}): field "${key}" is DEPRECATED — rename to ` +
214
+ `"${DEPRECATED_SOURCE_ALIASES[key]}" (normalized for now; support is removed after the ` +
215
+ `deprecation window).`
216
+ );
217
+ } else {
218
+ warnings.push(
219
+ ` ⚠ ${base} sources[${i}] (${src.path}): unknown field "${key}" is ignored — check for a ` +
220
+ `typo (recognized: ${recognized}).`
221
+ );
222
+ }
223
+ }
224
+ });
225
+
226
+ return warnings;
227
+ }
228
+
229
+ /**
230
+ * Validate a parsed manifest object against the schema.
231
+ * Required: kb_name (string), sources (non-empty array).
232
+ * Each source: path (string) required; ref, tier, doc_type, syncignore optional.
233
+ * KB-curation (CEO-D-2026-07-11): manifest-level `publish_tier` ("P"|"I"|"U",
234
+ * default "I") + per-source `doc_class`, `license_basis`, `lint_exempt`,
235
+ * `exempt_reason`, `raw_path` (all optional). A `publish_tier:"P"` manifest hard-
236
+ * fails if any source lacks a publishable `doc_class` (no default is allowed).
237
+ * github block: optional, validated but not used for sync_mode: "local".
238
+ *
239
+ * @param {Object} manifest - Parsed JSON manifest
240
+ * @param {string} filePath - Path to manifest file (for error messages)
241
+ * @throws {Error} If validation fails
242
+ */
243
+ function validateManifest(manifest, filePath, ownRepoSlug = null) {
244
+ if (!manifest || typeof manifest !== 'object') {
245
+ throw new Error(`Invalid manifest at ${filePath}: not a JSON object`);
246
+ }
247
+
248
+ if (!manifest.kb_name || typeof manifest.kb_name !== 'string') {
249
+ throw new Error(`Invalid manifest at ${filePath}: kb_name (string) is required`);
250
+ }
251
+
252
+ if (manifest.publish_tier !== undefined) {
253
+ if (typeof manifest.publish_tier !== 'string' || !PUBLISH_TIERS.has(manifest.publish_tier)) {
254
+ throw new Error(`Invalid manifest at ${filePath}: publish_tier must be one of "P", "I", "U"`);
255
+ }
256
+ }
257
+ const isTierP = manifest.publish_tier === 'P';
258
+
259
+ if (!Array.isArray(manifest.sources) || manifest.sources.length === 0) {
260
+ throw new Error(`Invalid manifest at ${filePath}: sources (non-empty array) is required`);
261
+ }
262
+
263
+ for (let i = 0; i < manifest.sources.length; i++) {
264
+ const src = manifest.sources[i];
265
+ if (!src.path || typeof src.path !== 'string') {
266
+ throw new Error(`Invalid manifest at ${filePath}: sources[${i}].path (string) is required`);
267
+ }
268
+ // Optional fields: ref (string), tier (number), doc_type (string), syncignore (string[])
269
+ if (src.ref !== undefined && typeof src.ref !== 'string') {
270
+ throw new Error(`Invalid manifest at ${filePath}: sources[${i}].ref must be a string`);
271
+ }
272
+ if (src.tier !== undefined && typeof src.tier !== 'number') {
273
+ throw new Error(`Invalid manifest at ${filePath}: sources[${i}].tier must be a number`);
274
+ }
275
+ if (src.doc_type !== undefined && typeof src.doc_type !== 'string') {
276
+ throw new Error(`Invalid manifest at ${filePath}: sources[${i}].doc_type must be a string`);
277
+ }
278
+ if (src.syncignore !== undefined && !Array.isArray(src.syncignore)) {
279
+ throw new Error(`Invalid manifest at ${filePath}: sources[${i}].syncignore must be an array`);
280
+ }
281
+ // KB-curation optional per-source fields.
282
+ if (src.doc_class !== undefined && typeof src.doc_class !== 'string') {
283
+ throw new Error(`Invalid manifest at ${filePath}: sources[${i}].doc_class must be a string`);
284
+ }
285
+ if (src.license_basis !== undefined && typeof src.license_basis !== 'string') {
286
+ throw new Error(`Invalid manifest at ${filePath}: sources[${i}].license_basis must be a string`);
287
+ }
288
+ if (src.lint_exempt !== undefined && !Array.isArray(src.lint_exempt)) {
289
+ throw new Error(`Invalid manifest at ${filePath}: sources[${i}].lint_exempt must be an array`);
290
+ }
291
+ if (src.exempt_reason !== undefined && typeof src.exempt_reason !== 'string') {
292
+ throw new Error(`Invalid manifest at ${filePath}: sources[${i}].exempt_reason must be a string`);
293
+ }
294
+ if (src.raw_path !== undefined && typeof src.raw_path !== 'string') {
295
+ throw new Error(`Invalid manifest at ${filePath}: sources[${i}].raw_path must be a string`);
296
+ }
297
+
298
+ // Two-shape source contract (interface-ws-c4-I2-manifest-source): adjacency/glob paths and
299
+ // self-naming or URL-shaped `repo` values are REFUSED here, loudly, naming the migration.
300
+ assertSourceShape(src, i, filePath, ownRepoSlug);
301
+
302
+ // Tier-P: every source MUST carry a publishable doc_class (no default allowed).
303
+ if (isTierP) {
304
+ if (!src.doc_class) {
305
+ throw new Error(
306
+ `Invalid manifest at ${filePath}: sources[${i}] (${src.path}) requires a doc_class ` +
307
+ `in a Tier-P (publish_tier:"P") manifest — no default is allowed.`
308
+ );
309
+ }
310
+ if (DENY_DOC_CLASSES.has(src.doc_class)) {
311
+ throw new Error(
312
+ `Invalid manifest at ${filePath}: sources[${i}] (${src.path}) doc_class "${src.doc_class}" ` +
313
+ `is a deny-class {${[...DENY_DOC_CLASSES].join(', ')}} — structurally invalid in a Tier-P manifest.`
314
+ );
315
+ }
316
+ if (!PUBLISHABLE_DOC_CLASSES.has(src.doc_class)) {
317
+ throw new Error(
318
+ `Invalid manifest at ${filePath}: sources[${i}] (${src.path}) doc_class "${src.doc_class}" ` +
319
+ `is not a publishable class {${[...PUBLISHABLE_DOC_CLASSES].join(', ')}}.`
320
+ );
321
+ }
322
+ }
323
+ }
324
+
325
+ // github block: validate structure if present, but skip for local sync
326
+ if (manifest.github !== undefined) {
327
+ if (typeof manifest.github !== 'object') {
328
+ throw new Error(`Invalid manifest at ${filePath}: github must be an object`);
329
+ }
330
+ }
331
+ }
332
+
333
+ /**
334
+ * Load a single manifest file.
335
+ *
336
+ * @param {string} manifestPath - Absolute path to manifest JSON file
337
+ * @param {string} workspaceRoot - Workspace root for resolving relative paths
338
+ * @returns {Promise<Object>} Validated manifest with resolved paths
339
+ */
340
+ export async function loadManifest(manifestPath, workspaceRoot, options = {}) {
341
+ const raw = await fs.readFile(manifestPath, 'utf-8');
342
+ const manifest = JSON.parse(raw);
343
+
344
+ // The repository that physically contains this manifest — the thing `repo` may never name.
345
+ // Injectable so tests drive the check without depending on the ambient checkout.
346
+ const ownRepoSlug = options.ownRepoSlug !== undefined
347
+ ? options.ownRepoSlug
348
+ : detectRepoSlug(path.dirname(manifestPath));
349
+
350
+ validateManifest(manifest, manifestPath, ownRepoSlug);
351
+
352
+ // Canonical-schema warnings (WS-EVP-DESCIX-KB-CLEANUP item 5): surface deprecated aliases and
353
+ // unknown/misspelled fields during the deprecation window — warn, never throw, so both legacy
354
+ // formats keep syncing.
355
+ for (const w of collectManifestSchemaWarnings(manifest, manifestPath)) {
356
+ console.warn(w);
357
+ }
358
+
359
+ // Resolve source paths relative to workspace root
360
+ manifest._resolvedSources = manifest.sources.map(src => {
361
+ // Normalize deprecated field aliases (src_path -> raw_path). Canonical field wins if both set.
362
+ const s = { ...src };
363
+ for (const [alias, canonical] of Object.entries(DEPRECATED_SOURCE_ALIASES)) {
364
+ if (s[alias] !== undefined && s[canonical] === undefined) {
365
+ s[canonical] = s[alias];
366
+ }
367
+ }
368
+ return {
369
+ ...s,
370
+ // `repo` stays UNDEFINED when omitted — omission is the single spelling of "here" and must
371
+ // survive the load, so an in-repo source is never silently rewritten into a cross-repo one.
372
+ repo: s.repo === undefined ? undefined : String(s.repo).trim(),
373
+ absolutePath: path.resolve(workspaceRoot, s.path),
374
+ ref: s.ref || DEFAULT_SOURCE_REF,
375
+ tier: s.tier || 3,
376
+ doc_type: s.doc_type || 'generic',
377
+ syncignore: s.syncignore || [],
378
+ // KB-curation carry-through. doc_class is as-is (may be undefined for tier I).
379
+ doc_class: s.doc_class,
380
+ license_basis: s.license_basis ?? null,
381
+ lint_exempt: s.lint_exempt || [],
382
+ exempt_reason: s.exempt_reason ?? null,
383
+ raw_path: s.raw_path ?? null
384
+ };
385
+ });
386
+
387
+ // Surface publish_tier on the manifest object (default "I") so corpus.js can
388
+ // read manifest.publish_tier to decide whether to run the Tier-P deny lint.
389
+ manifest.publish_tier = manifest.publish_tier || 'I';
390
+
391
+ manifest._manifestPath = manifestPath;
392
+ return manifest;
393
+ }
394
+
395
+ /**
396
+ * Discover and load all manifests for an app.
397
+ * Looks in {appRoot}/.descix/manifests/*.json
398
+ *
399
+ * @param {string} appRoot - Absolute path to app root directory
400
+ * @param {string} workspaceRoot - Workspace root for resolving relative paths
401
+ * @param {string} [filterKbName] - If provided, only load manifest with this kb_name
402
+ * @returns {Promise<Array<Object>>} Array of validated manifest objects
403
+ */
404
+ export async function loadManifests(appRoot, workspaceRoot, filterKbName = null) {
405
+ const manifestsDir = path.join(appRoot, '.descix', 'manifests');
406
+
407
+ let files;
408
+ try {
409
+ files = await fs.readdir(manifestsDir);
410
+ } catch {
411
+ return []; // No manifests directory — not an error
412
+ }
413
+
414
+ // M2 (2026-04-20): skip non-KB manifests.
415
+ // {app}/.descix/manifests/ can hold schemas other than KB manifests
416
+ // (notably site.json, which has its own schema — see loadSiteManifest).
417
+ // The legacy loader validated every .json as a KB manifest and blocked
418
+ // 'descix kb corpus sync' with `kb_name required` whenever a site.json
419
+ // was present. We now:
420
+ // (a) explicitly skip known non-KB filenames (site.json)
421
+ // (b) try-catch each load and treat validation failures as "not a KB
422
+ // manifest" when the cause is a missing kb_name, rather than
423
+ // aborting the whole operation.
424
+ // Any other error (I/O, malformed JSON) is still propagated with context.
425
+ const NON_KB_MANIFESTS = new Set(['site.json']);
426
+ const jsonFiles = files.filter(f => f.endsWith('.json') && !NON_KB_MANIFESTS.has(f));
427
+ const manifests = [];
428
+
429
+ for (const file of jsonFiles) {
430
+ const manifestPath = path.join(manifestsDir, file);
431
+ let manifest;
432
+ try {
433
+ manifest = await loadManifest(manifestPath, workspaceRoot);
434
+ } catch (err) {
435
+ // If the file is missing kb_name, it's not a KB manifest — skip it
436
+ // with a warning rather than aborting the whole operation. This
437
+ // lets future non-KB schemas coexist without editing this allowlist.
438
+ if (/kb_name \(string\) is required/.test(err.message)) {
439
+ console.warn(` ⚠ Skipping non-KB manifest: ${file} (${err.message})`);
440
+ continue;
441
+ }
442
+ throw err;
443
+ }
444
+
445
+ if (filterKbName && manifest.kb_name !== filterKbName) {
446
+ continue;
447
+ }
448
+
449
+ manifests.push(manifest);
450
+ }
451
+
452
+ return manifests;
453
+ }
454
+
455
+ // ============ Site Manifest Support ============
456
+
457
+ /**
458
+ * Validate a site manifest against its schema.
459
+ * Required: sources (non-empty array).
460
+ * Each source: path (string) required; include, exclude optional string arrays.
461
+ * buildCommand: optional string.
462
+ *
463
+ * @param {Object} manifest - Parsed JSON manifest
464
+ * @param {string} filePath - Path to manifest file (for error messages)
465
+ * @throws {Error} If validation fails
466
+ */
467
+ function validateSiteManifest(manifest, filePath) {
468
+ if (!manifest || typeof manifest !== 'object') {
469
+ throw new Error(`Invalid site manifest at ${filePath}: not a JSON object`);
470
+ }
471
+
472
+ if (!Array.isArray(manifest.sources) || manifest.sources.length === 0) {
473
+ throw new Error(`Invalid site manifest at ${filePath}: sources (non-empty array) is required`);
474
+ }
475
+
476
+ for (let i = 0; i < manifest.sources.length; i++) {
477
+ const src = manifest.sources[i];
478
+ if (!src.path || typeof src.path !== 'string') {
479
+ throw new Error(`Invalid site manifest at ${filePath}: sources[${i}].path (string) is required`);
480
+ }
481
+ if (src.include !== undefined && !Array.isArray(src.include)) {
482
+ throw new Error(`Invalid site manifest at ${filePath}: sources[${i}].include must be a string array`);
483
+ }
484
+ if (src.exclude !== undefined && !Array.isArray(src.exclude)) {
485
+ throw new Error(`Invalid site manifest at ${filePath}: sources[${i}].exclude must be a string array`);
486
+ }
487
+ }
488
+
489
+ if (manifest.buildCommand !== undefined && manifest.buildCommand !== null && typeof manifest.buildCommand !== 'string') {
490
+ throw new Error(`Invalid site manifest at ${filePath}: buildCommand must be a string or null`);
491
+ }
492
+ }
493
+
494
+ /**
495
+ * Load a site manifest from {appRoot}/.descix/manifests/site.json.
496
+ *
497
+ * Resolves source paths relative to the app's localPath in workspace.json.
498
+ * Each source entry gets include/exclude defaults applied.
499
+ *
500
+ * @param {string} appRoot - Absolute path to the app root directory
501
+ * @returns {Promise<Object|null>} Validated site manifest with _resolvedSources, or null if not found
502
+ */
503
+ export async function loadSiteManifest(appRoot) {
504
+ const manifestPath = path.join(appRoot, '.descix', 'manifests', 'site.json');
505
+
506
+ let raw;
507
+ try {
508
+ raw = await fs.readFile(manifestPath, 'utf-8');
509
+ } catch (err) {
510
+ if (err.code === 'ENOENT') {
511
+ return null; // No site manifest — not an error
512
+ }
513
+ throw err;
514
+ }
515
+
516
+ const manifest = JSON.parse(raw);
517
+ validateSiteManifest(manifest, manifestPath);
518
+
519
+ // Resolve source paths relative to app root
520
+ manifest._resolvedSources = manifest.sources.map(src => ({
521
+ sourcePath: src.path,
522
+ absolutePath: path.resolve(appRoot, src.path),
523
+ include: src.include || [], // empty = include everything
524
+ exclude: src.exclude || [],
525
+ }));
526
+
527
+ manifest._manifestPath = manifestPath;
528
+ manifest.buildCommand = manifest.buildCommand || null;
529
+
530
+ return manifest;
531
+ }
532
+
533
+ export default { loadManifest, loadManifests, loadSiteManifest };