@descix/cli 1.0.5 → 1.0.7
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 +130 -34
- package/agent-assets/guides/dynamic-site-pattern.md +10 -14
- package/agent-assets/guides/knowledge-only-pattern.md +11 -15
- package/agent-assets/guides/microservice-pattern.md +12 -14
- package/agent-assets/guides/static-site-pattern.md +12 -15
- package/agent-assets/instructions/onboarding-flow.md +4 -4
- package/agent-assets/reference/app-creation.md +36 -101
- package/agent-assets/reference/architecture.md +24 -27
- package/agent-assets/reference/local-dev.md +0 -4
- package/agent-assets/reference/mcp-integration.md +14 -22
- package/agent-assets/reference/workspace-config.md +97 -340
- package/agent-assets/rules/descix_mcp.template.mdc +1 -1
- package/bin/descix.js +159 -98
- package/lib/api-client.js +30 -22
- package/lib/commands/airdrop.js +10 -0
- package/lib/commands/auth.js +33 -3
- package/lib/commands/briefer/index.js +8 -5
- package/lib/commands/config.js +13 -31
- package/lib/commands/corpus.js +161 -54
- package/lib/commands/health.js +116 -21
- package/lib/commands/init.js +25 -48
- package/lib/commands/kb.js +14 -4
- package/lib/commands/update.js +0 -1
- package/lib/core/Chunker.js +1 -1
- package/lib/core/CorpusWalker.js +238 -47
- package/lib/core/Hydrator.js +88 -11
- package/lib/core/ManifestLoader.js +68 -0
- package/lib/core/Syncer.js +147 -46
- package/lib/interactive.js +158 -0
- package/lib/origin.js +3 -3
- package/lib/wallet-file.js +36 -37
- package/lib/wizard/setup.js +104 -22
- package/lib/workspace-config.js +325 -167
- package/lib/workspace-identity.js +0 -1
- package/package.json +3 -3
- package/templates/agent-claude.md +0 -3
- package/templates/agent-cline.md +0 -3
- package/templates/agent-copilot.md +0 -3
- package/templates/agent-cursor.md +0 -3
- package/templates/drive/app/kb/General/README.md +1 -1
- package/templates/drive_templates/agent_app_template/kb/General/README.md +1 -1
- package/templates/scaffolds/microservice/SERVICE_README_sdk.md +3 -3
- package/templates/scaffolds/microservice/defaults-config.json +3 -1
- package/templates/scaffolds/microservice/manifest.json +9 -5
- package/templates/scaffolds/microservice/package.json +1 -1
- package/templates/scaffolds/microservice/services/commandHandlers/sampleCommands.js +2 -2
package/lib/core/CorpusWalker.js
CHANGED
|
@@ -7,16 +7,19 @@
|
|
|
7
7
|
* Architecture:
|
|
8
8
|
* - Files stay in place — no copying or staging
|
|
9
9
|
* - Git blob SHAs provide versioning (same content = same SHA)
|
|
10
|
-
* -
|
|
10
|
+
* - GIT IS THE ONLY SOURCE OF TRUTH FOR WHAT IS A CORPUS SOURCE: a file must be TRACKED
|
|
11
|
+
* AT THE MANIFEST'S REF or it is refused by name. There is no working-tree fallback.
|
|
11
12
|
* - Chunk IDs are content-addressed: {app_id}:{kb_name}:{blob_sha}:{chunk_idx}
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
15
|
import * as fs from 'fs/promises';
|
|
16
|
+
import * as fsSync from 'fs';
|
|
15
17
|
import * as path from 'path';
|
|
16
18
|
import { execSync } from 'child_process';
|
|
17
19
|
import { minimatch } from 'minimatch';
|
|
18
|
-
// The source-ref default
|
|
19
|
-
|
|
20
|
+
// The source-ref default and "which repository owns this file" are both owned by
|
|
21
|
+
// ManifestLoader — consume them, never re-default and never re-derive.
|
|
22
|
+
import { DEFAULT_SOURCE_REF, resolveOwningRepo } from './ManifestLoader.js';
|
|
20
23
|
|
|
21
24
|
/**
|
|
22
25
|
* File extensions that are processable for RAG chunking.
|
|
@@ -60,42 +63,132 @@ function isIgnored(relativePath, syncignore) {
|
|
|
60
63
|
return false;
|
|
61
64
|
}
|
|
62
65
|
|
|
66
|
+
const SHA40 = /^[0-9a-f]{40}$/i;
|
|
67
|
+
|
|
63
68
|
/**
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
69
|
+
* Explain WHICH WAY a path failed the "tracked at ref" predicate. Purely diagnostic —
|
|
70
|
+
* it never decides anything, so there is still exactly one decision site. Each branch
|
|
71
|
+
* ends in an instruction the operator can act on without further investigation.
|
|
67
72
|
*
|
|
68
|
-
* @
|
|
69
|
-
* @param {string} workspaceRelativePath - Path relative to workspace root
|
|
70
|
-
* @param {string} ref - Git ref (branch/tag)
|
|
71
|
-
* @param {string} workspaceRoot - Workspace root directory
|
|
72
|
-
* @returns {string} Git blob SHA (40 hex chars)
|
|
73
|
+
* @returns {string} the reason string embedded in the aggregated refusal
|
|
73
74
|
*/
|
|
74
|
-
function
|
|
75
|
+
function diagnoseUntracked(repoRelativePath, ref, repoRoot, repoLabel) {
|
|
76
|
+
// Every diagnostic below runs IN THE OWNING REPOSITORY. Running them at the ambient
|
|
77
|
+
// workspace root is what produced the maximally wrong advice this gate was blocked for
|
|
78
|
+
// ("UNTRACKED — commit it on main" about content that was already committed, and
|
|
79
|
+
// "IGNORED BY GIT" about a sibling repo the SUPERREPO ignores but which tracks it fine).
|
|
80
|
+
const where = `in ${repoLabel}`;
|
|
81
|
+
|
|
82
|
+
// (c) Present at HEAD but absent at THIS ref — checked first, because .gitignore
|
|
83
|
+
// patterns do not apply to tracked files and would misdiagnose this case.
|
|
75
84
|
try {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
85
|
+
execSync(`git rev-parse "HEAD:${repoRelativePath}"`,
|
|
86
|
+
{ cwd: repoRoot, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
87
|
+
return `present at HEAD but ABSENT AT REF "${ref}" ${where} — the manifest pins a ref ` +
|
|
88
|
+
`that predates this file; commit it on "${ref}" or point the source at the right ref`;
|
|
89
|
+
} catch { /* not at HEAD either — fall through */ }
|
|
90
|
+
|
|
91
|
+
// (a) Deliberately excluded from the repository. The measured defect class.
|
|
92
|
+
try {
|
|
93
|
+
const hit = execSync(`git check-ignore -v "${repoRelativePath}"`,
|
|
94
|
+
{ cwd: repoRoot, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
95
|
+
).trim().split('\n')[0];
|
|
96
|
+
if (hit) {
|
|
97
|
+
return `IGNORED BY GIT (${hit.split('\t')[0]}) ${where} and not tracked at ref ` +
|
|
98
|
+
`"${ref}" — content the repository deliberately excludes must never become a ` +
|
|
99
|
+
`citable KB source; remove this source from the manifest`;
|
|
100
|
+
}
|
|
101
|
+
} catch { /* not ignored — fall through */ }
|
|
102
|
+
|
|
103
|
+
// (b) Simply never committed.
|
|
104
|
+
return `UNTRACKED at ref "${ref}" ${where} — the file exists on disk but that repository ` +
|
|
105
|
+
`does not track it; commit it on "${ref}", or remove this source from the manifest`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* THE SOURCE GATE — the single owner of "is this file a legitimate corpus source".
|
|
110
|
+
*
|
|
111
|
+
* A source is legitimate iff GIT TRACKS IT AT THE MANIFEST'S REF. That one predicate is
|
|
112
|
+
* the whole rule, and it is deliberately not split by WHY a path fails it: gitignored
|
|
113
|
+
* scratch, never-committed, and absent-at-an-older-ref are all "not tracked at ref".
|
|
114
|
+
* Splitting them would re-derive "what is a corpus source" in a second place, and the
|
|
115
|
+
* copies would drift.
|
|
116
|
+
*
|
|
117
|
+
* THE WORKING TREE IS NEVER CONSULTED. Reading a sha from disk when the ref lookup failed
|
|
118
|
+
* is the measured defect: it served content git was explicitly told to exclude as a
|
|
119
|
+
* citable KB source (corpus:04406066... = `.claude/agent-memory/evp-descix/qa-pairs.jsonl`,
|
|
120
|
+
* excluded by .gitignore, chunked and served anyway). That path is deleted, not fenced
|
|
121
|
+
* behind a flag, and there is nothing that restores it — SUPER-DRY, CEO-D-2026-07-26.
|
|
122
|
+
*
|
|
123
|
+
* THE REF IS RESOLVED IN THE REPOSITORY THAT OWNS THE FILE, never in an ambient root. A
|
|
124
|
+
* workspace spans several repositories (superrepo, submodules, ignored sibling checkouts)
|
|
125
|
+
* and a ref names a branch in exactly one of them. Asking the superrepo about a file inside
|
|
126
|
+
* a submodule asks the wrong repository a question it cannot answer — it holds only a
|
|
127
|
+
* gitlink commit — and the honest-looking "not tracked" that comes back is FALSE. That was
|
|
128
|
+
* measured on this very branch: 596 committed files refused, the two largest live KBs
|
|
129
|
+
* stopped, with advice telling the operator to commit content already committed.
|
|
130
|
+
* "Which repository owns this file" has ONE owner: ManifestLoader::resolveOwningRepo.
|
|
131
|
+
*
|
|
132
|
+
* Returns a REASON rather than throwing so the caller can aggregate every offender into
|
|
133
|
+
* one refusal instead of failing on the first (mirroring CorpusDenyLint's report/assert
|
|
134
|
+
* split — the established refusal shape in this codebase).
|
|
135
|
+
*
|
|
136
|
+
* @param {string} absoluteFilePath - Absolute path to the file under test
|
|
137
|
+
* @param {string} ref - Git ref (branch/tag) the manifest source named
|
|
138
|
+
* @param {string} [workspaceRoot] - Only to label paths in messages; NEVER a resolution root
|
|
139
|
+
* @returns {{sha: string|null, reason: string|null, repoRoot: string|null}}
|
|
140
|
+
*/
|
|
141
|
+
export function resolveTrackedBlobSha(absoluteFilePath, ref, workspaceRoot = null) {
|
|
142
|
+
const owner = resolveOwningRepo(absoluteFilePath);
|
|
143
|
+
if (!owner) {
|
|
144
|
+
return {
|
|
145
|
+
sha: null, repoRoot: null,
|
|
146
|
+
reason: `NOT INSIDE ANY GIT REPOSITORY — no repository owns this path, so ref "${ref}" ` +
|
|
147
|
+
`cannot be resolved for it and nothing can vouch for its content; remove this ` +
|
|
148
|
+
`source from the manifest, or put the content in a repository and commit it`
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
const { repoRoot, repoRelativePath } = owner;
|
|
152
|
+
// How the owning repo is named in messages: relative to the workspace when we have one,
|
|
153
|
+
// so the operator reads "DeSciX/DeSciX_Cloud" rather than an absolute machine path.
|
|
154
|
+
// `repoRoot` is a REAL path (git resolves symlinks), so the workspace root must be one
|
|
155
|
+
// too or the subtraction escapes upward into `../../..`.
|
|
156
|
+
let workspaceReal = workspaceRoot;
|
|
157
|
+
try { if (workspaceRoot) workspaceReal = fsSync.realpathSync(workspaceRoot); } catch { /* keep as given */ }
|
|
158
|
+
const repoLabel = workspaceRoot ? (path.relative(workspaceReal, repoRoot) || '.') : repoRoot;
|
|
159
|
+
|
|
160
|
+
let sha;
|
|
161
|
+
try {
|
|
162
|
+
sha = execSync(
|
|
163
|
+
`git rev-parse "${ref}:${repoRelativePath}"`,
|
|
164
|
+
{ cwd: repoRoot, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
80
165
|
).trim();
|
|
81
|
-
return sha;
|
|
82
166
|
} catch {
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
}
|
|
167
|
+
// ONE PREDICATE ("tracked at ref, in the owning repo"), FOUR DIAGNOSTICS. The decision
|
|
168
|
+
// has already been made above — everything below only explains WHICH WAY the path
|
|
169
|
+
// failed, so DRY holds and each population gets a self-curing instruction.
|
|
170
|
+
return { sha: null, repoRoot, reason: diagnoseUntracked(repoRelativePath, ref, repoRoot, repoLabel) };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!SHA40.test(sha)) {
|
|
174
|
+
return {
|
|
175
|
+
sha: null, repoRoot,
|
|
176
|
+
reason: `git rev-parse "${ref}:${repoRelativePath}" in ${repoLabel} returned "${sha}", which is not a blob sha`
|
|
177
|
+
};
|
|
95
178
|
}
|
|
179
|
+
return { sha, repoRoot, reason: null };
|
|
96
180
|
}
|
|
97
181
|
|
|
98
|
-
|
|
182
|
+
/**
|
|
183
|
+
* What this gate compares, catches, and does NOT read — printed with the verdict so a
|
|
184
|
+
* reader of a GREEN sees where the green stops, in the gate's own words.
|
|
185
|
+
*/
|
|
186
|
+
export const SOURCE_GATE_BOUNDARY = Object.freeze({
|
|
187
|
+
compares: 'each walked file against `git rev-parse <manifest source ref>:<path relative to THE REPOSITORY THAT OWNS THE FILE>`, where the owner is the nearest enclosing git repository (ManifestLoader::resolveOwningRepo) — the superrepo, a submodule, or an ignored sibling checkout',
|
|
188
|
+
catches: 'any corpus source not tracked at its ref BY ITS OWN REPOSITORY — gitignored scratch, never-committed files, paths absent from an older ref, and paths inside no repository at all',
|
|
189
|
+
does_not_read: 'file CONTENT (see CorpusDenyLint for content/path deny-classes), Pinecone live state, and cross-repo `repo:` sources (refused separately by resolveSourceProvenance). It also does NOT check that a submodule\'s ref matches the commit the superrepo pins at its gitlink: a source is walked at the ref it names in the repository that owns it, so content committed on that branch but not yet pinned by the superrepo is accepted.',
|
|
190
|
+
runs_automatically: 'yes — inside walkCorpus, so every `descix kb corpus sync` passes through it; there is no flag that skips it'
|
|
191
|
+
});
|
|
99
192
|
|
|
100
193
|
/**
|
|
101
194
|
* Resolve ONE source's provenance: which repository, which ref, and the exact commit synced.
|
|
@@ -117,33 +210,62 @@ const SHA40 = /^[0-9a-f]{40}$/i;
|
|
|
117
210
|
export async function resolveSourceProvenance(source, workspaceRoot) {
|
|
118
211
|
const ref = source.ref || DEFAULT_SOURCE_REF;
|
|
119
212
|
const repo = source.repo ?? null;
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
213
|
+
// `source_repo` is a LEGACY DISPLAY LABEL that rides into chunk metadata — NOT the
|
|
214
|
+
// ownership decision. Ownership is decided in exactly one place (resolveOwningRepo);
|
|
215
|
+
// this leading-path-segment value is preserved verbatim because rewriting it would
|
|
216
|
+
// re-stamp every existing chunk in every live KB. Do not read it as "which repository
|
|
217
|
+
// owns this file"; for that, ask the owner.
|
|
123
218
|
const source_repo = repo ?? (String(source.path || '').split('/')[0] || 'unknown');
|
|
124
219
|
|
|
125
220
|
if (!repo) {
|
|
221
|
+
// Resolve the commit IN THE REPOSITORY THAT OWNS THE SOURCE — the same one owner the
|
|
222
|
+
// gate consults. Resolving it at the ambient workspace root would record the
|
|
223
|
+
// SUPERREPO's commit for content whose blobs came from a submodule or a sibling repo:
|
|
224
|
+
// two derivations of "where did this content come from" that disagree, which is the
|
|
225
|
+
// mirror-drift this file already refuses elsewhere. A provenance record that names the
|
|
226
|
+
// wrong repository's commit is a FALSE record, not an approximate one.
|
|
227
|
+
const owner = resolveOwningRepo(source.absolutePath);
|
|
228
|
+
if (!owner) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
`Source "${source.path}" resolves to ${source.absolutePath}, which is inside NO git ` +
|
|
231
|
+
`repository. Refusing to record provenance — there is no repository whose ref "${ref}" ` +
|
|
232
|
+
`could describe this content.`
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
const provenanceRoot = owner.repoRoot;
|
|
126
236
|
let sha;
|
|
127
237
|
try {
|
|
128
238
|
sha = execSync(`git rev-parse "${ref}^{commit}"`, {
|
|
129
|
-
cwd:
|
|
239
|
+
cwd: provenanceRoot, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
130
240
|
}).trim();
|
|
131
241
|
} catch {
|
|
132
242
|
throw new Error(
|
|
133
|
-
`Cannot resolve ref "${ref}" to a commit in ${
|
|
134
|
-
`Refusing to record provenance — a sync-state commit of
|
|
135
|
-
`missing one. Check that the ref exists
|
|
243
|
+
`Cannot resolve ref "${ref}" to a commit in ${provenanceRoot} (the repository that owns ` +
|
|
244
|
+
`source "${source.path}") . Refusing to record provenance — a sync-state commit of ` +
|
|
245
|
+
`"unknown" is a FALSE record, not a missing one. Check that the ref exists in THAT ` +
|
|
246
|
+
`repository, which is not necessarily the workspace root ${workspaceRoot}.`
|
|
136
247
|
);
|
|
137
248
|
}
|
|
138
249
|
if (!SHA40.test(sha)) {
|
|
139
250
|
throw new Error(`Ref "${ref}" resolved to "${sha}", which is not a commit sha (source "${source.path}").`);
|
|
140
251
|
}
|
|
141
|
-
|
|
252
|
+
// `repo_root` MAKES THE RECORDED COMMIT SELF-DESCRIBING. Before this, every commit in
|
|
253
|
+
// sync-state was a workspace-root commit, so a reader could resolve it at the workspace
|
|
254
|
+
// root and be right by accident. Now a commit may belong to a submodule or a sibling
|
|
255
|
+
// repo, and the SAME 40 hex characters mean different things depending on which
|
|
256
|
+
// repository you ask. Writing the owning repo beside the commit is what keeps the store
|
|
257
|
+
// homogeneous: a reader resolves the commit WHERE THIS SAYS, and a record written before
|
|
258
|
+
// this field existed is missing it and can be detected rather than silently misread.
|
|
259
|
+
return {
|
|
260
|
+
repo: null, ref, resolved_commit_sha: sha, source_repo,
|
|
261
|
+
repo_root: path.relative(workspaceRoot, provenanceRoot) || '.',
|
|
262
|
+
};
|
|
142
263
|
}
|
|
143
264
|
|
|
144
|
-
// CROSS-REPO: resolve against the remote. Never a local read.
|
|
265
|
+
// CROSS-REPO: resolve against the remote. Never a local read. `repo_root` is null because
|
|
266
|
+
// the content is not in this workspace at all — `repo` already says where it lives.
|
|
145
267
|
if (SHA40.test(ref)) {
|
|
146
|
-
return { repo, ref, resolved_commit_sha: ref.toLowerCase(), source_repo };
|
|
268
|
+
return { repo, ref, resolved_commit_sha: ref.toLowerCase(), source_repo, repo_root: null };
|
|
147
269
|
}
|
|
148
270
|
const remoteUrl = `https://github.com/${repo}.git`;
|
|
149
271
|
let out;
|
|
@@ -167,7 +289,7 @@ export async function resolveSourceProvenance(source, workspaceRoot) {
|
|
|
167
289
|
`Repo "${repo}" has no ref "${ref}" (source "${source.path}"). REFUSING rather than guessing.`
|
|
168
290
|
);
|
|
169
291
|
}
|
|
170
|
-
return { repo, ref, resolved_commit_sha: sha, source_repo };
|
|
292
|
+
return { repo, ref, resolved_commit_sha: sha, source_repo, repo_root: null };
|
|
171
293
|
}
|
|
172
294
|
|
|
173
295
|
/**
|
|
@@ -247,6 +369,9 @@ async function isFile(filePath) {
|
|
|
247
369
|
export async function walkCorpus(manifest, workspaceRoot) {
|
|
248
370
|
const files = [];
|
|
249
371
|
const provenance = [];
|
|
372
|
+
// Every file the source gate refused, collected so ONE error can name them all. A
|
|
373
|
+
// first-throw would hide offenders 2..N and turn one fix into N sync attempts.
|
|
374
|
+
const refused = [];
|
|
250
375
|
let commitSha = 'unknown';
|
|
251
376
|
|
|
252
377
|
// Nested-source dedup (WS-EVP-DESCIX-KB-CLEANUP item 4): a manifest may list both a directory
|
|
@@ -276,6 +401,39 @@ export async function walkCorpus(manifest, workspaceRoot) {
|
|
|
276
401
|
const { absolutePath, ref, tier, doc_type, syncignore } = source;
|
|
277
402
|
const thisSourceRoot = path.resolve(absolutePath);
|
|
278
403
|
|
|
404
|
+
// An IN-REPO source whose path lies in NO repository joins the AGGREGATED refusal rather
|
|
405
|
+
// than throwing. Both outcomes refuse — but throwing would abort on the first offending
|
|
406
|
+
// source and hide every later one, turning one manifest fix into N sync attempts, which
|
|
407
|
+
// is the whole reason this walk aggregates. Measured: `EGPT/` (in no repo) aborted the
|
|
408
|
+
// EVP-EGPT walk before its other sources were ever examined.
|
|
409
|
+
//
|
|
410
|
+
// CROSS-REPO SOURCES ARE DELIBERATELY EXEMPT. Their `absolutePath` is a fiction — the
|
|
411
|
+
// content lives in another repository — so "is it in a repo here" is the wrong question
|
|
412
|
+
// and answering it would MASK the accurate refusal resolveSourceProvenance already owns.
|
|
413
|
+
// Measured: EVP-BEAST's four `repo: eabadir/unk` sources were being reported as "not
|
|
414
|
+
// inside any git repository" instead of "cross-repo content fetch is not implemented".
|
|
415
|
+
if (source.repo === undefined) {
|
|
416
|
+
const exists = await fs.access(absolutePath).then(() => true, () => false);
|
|
417
|
+
if (!exists) {
|
|
418
|
+
refused.push({
|
|
419
|
+
path: source.path, ref, source: source.path,
|
|
420
|
+
reason: `DOES NOT EXIST at ${absolutePath} — the manifest names a path that is not ` +
|
|
421
|
+
`on disk; correct the path or remove this source from the manifest`,
|
|
422
|
+
});
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
if (!resolveOwningRepo(absolutePath)) {
|
|
426
|
+
refused.push({
|
|
427
|
+
path: source.path, ref, source: source.path,
|
|
428
|
+
reason: `NOT INSIDE ANY GIT REPOSITORY (${absolutePath}) — no repository owns this ` +
|
|
429
|
+
`path, so ref "${ref}" cannot be resolved for it and nothing can vouch for ` +
|
|
430
|
+
`its content; remove this source from the manifest, or put the content in a ` +
|
|
431
|
+
`repository and commit it`,
|
|
432
|
+
});
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
279
437
|
// Resolve this source's exact provenance BEFORE reading anything. A cross-repo source that
|
|
280
438
|
// cannot be fetched REFUSES here, naming the repo — it is never silently skipped and never
|
|
281
439
|
// falls back to reading an adjacent working tree.
|
|
@@ -318,13 +476,17 @@ export async function walkCorpus(manifest, workspaceRoot) {
|
|
|
318
476
|
if (ownerSourceRoot(filePath) !== thisSourceRoot) {
|
|
319
477
|
continue;
|
|
320
478
|
}
|
|
321
|
-
//
|
|
479
|
+
// Workspace-relative path is the DISPLAY/metadata spelling only. Git resolution uses
|
|
480
|
+
// the owning repository — see resolveTrackedBlobSha.
|
|
322
481
|
const workspaceRelativePath = path.relative(workspaceRoot, filePath);
|
|
323
482
|
|
|
324
|
-
//
|
|
325
|
-
|
|
483
|
+
// THE SOURCE GATE. A file the OWNING repository does not track at this source's ref
|
|
484
|
+
// is REFUSED by name — never silently chunked from the working tree, and never
|
|
485
|
+
// silently skipped either (a skip on a clean worktree would PURGE its live chunks).
|
|
486
|
+
const { sha: blobSha, reason } = resolveTrackedBlobSha(filePath, ref, workspaceRoot);
|
|
326
487
|
if (!blobSha) {
|
|
327
|
-
|
|
488
|
+
refused.push({ path: workspaceRelativePath, ref, reason, source: source.path });
|
|
489
|
+
continue;
|
|
328
490
|
}
|
|
329
491
|
|
|
330
492
|
// "Which repo does this content belong to" has ONE owner: resolveSourceProvenance.
|
|
@@ -351,7 +513,36 @@ export async function walkCorpus(manifest, workspaceRoot) {
|
|
|
351
513
|
}
|
|
352
514
|
}
|
|
353
515
|
|
|
354
|
-
|
|
516
|
+
// ONE aggregated refusal naming every offender, its ref and its reason. Raised AFTER
|
|
517
|
+
// the full walk so the operator fixes the manifest once rather than N times, and BEFORE
|
|
518
|
+
// any caller can chunk, upsert or purge — the refused files are neither served nor
|
|
519
|
+
// treated as deletions.
|
|
520
|
+
if (refused.length > 0) {
|
|
521
|
+
// The error must be fixable IN ONE ACT from its text alone: it names the manifest
|
|
522
|
+
// FILE to edit and the offending ENTRY within it, not merely the resolved path.
|
|
523
|
+
// `_manifestPath` is owned by ManifestLoader — consume it, never re-derive it.
|
|
524
|
+
const manifestFile = manifest._manifestPath || `(manifest for kb "${manifest.kb_name}")`;
|
|
525
|
+
const lines = refused.map(
|
|
526
|
+
r => ` - ${r.path}\n ref: "${r.ref}" manifest entry: sources[] path "${r.source}"\n ${r.reason}`
|
|
527
|
+
);
|
|
528
|
+
const err = new Error(
|
|
529
|
+
`Corpus source gate FAILED for KB "${manifest.kb_name}": ${refused.length} file(s) are ` +
|
|
530
|
+
`not tracked by git at their manifest ref — refusing to sync.\n` +
|
|
531
|
+
`Manifest to edit: ${manifestFile}\n${lines.join('\n')}\n` +
|
|
532
|
+
`Every corpus source must be committed at its ref. There is no working-tree fallback: ` +
|
|
533
|
+
`content git excludes must never be served as a citable KB source.`
|
|
534
|
+
);
|
|
535
|
+
// The offenders as DATA, so a reporting tool consumes the gate's own verdict instead of
|
|
536
|
+
// re-implementing the predicate or scraping this prose. The message stays the human
|
|
537
|
+
// surface; `refused` is the machine one, and they cannot disagree because there is one
|
|
538
|
+
// decision behind both.
|
|
539
|
+
err.refused = refused;
|
|
540
|
+
err.kb_name = manifest.kb_name;
|
|
541
|
+
err.manifestPath = manifestFile;
|
|
542
|
+
throw err;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
return { files, commitSha, provenance, gateBoundary: SOURCE_GATE_BOUNDARY };
|
|
355
546
|
}
|
|
356
547
|
|
|
357
|
-
export default { walkCorpus, resolveSourceProvenance };
|
|
548
|
+
export default { walkCorpus, resolveSourceProvenance, resolveTrackedBlobSha, SOURCE_GATE_BOUNDARY };
|
package/lib/core/Hydrator.js
CHANGED
|
@@ -726,11 +726,15 @@ export async function checkStagingFiles(stagingDir) {
|
|
|
726
726
|
*
|
|
727
727
|
* @param {string} scaffoldType - 'site' or 'microservice'
|
|
728
728
|
* @param {string} appPath - Path to the app directory
|
|
729
|
-
* @param {Object} options - { verbose, force }
|
|
729
|
+
* @param {Object} options - { verbose, force, substitute }
|
|
730
|
+
* @param {{appId: string, communityId: string, appName?: string}} [options.substitute] - the
|
|
731
|
+
* caller's real identity. Supply it for any scaffold a developer will actually use: without it
|
|
732
|
+
* the copy is verbatim and every {{TOKEN}} ships unresolved. Omitting it is only appropriate
|
|
733
|
+
* when materializing the raw template itself.
|
|
730
734
|
* @returns {Promise<{copied: number}>}
|
|
731
735
|
*/
|
|
732
736
|
export async function copyScaffold(scaffoldType, appPath, options = {}) {
|
|
733
|
-
const { verbose = false, force = false } = options;
|
|
737
|
+
const { verbose = false, force = false, substitute = null } = options;
|
|
734
738
|
|
|
735
739
|
const scaffoldDir = SCAFFOLD_DIRS[scaffoldType];
|
|
736
740
|
if (!scaffoldDir) {
|
|
@@ -767,8 +771,36 @@ export async function copyScaffold(scaffoldType, appPath, options = {}) {
|
|
|
767
771
|
|
|
768
772
|
// Copy scaffold recursively
|
|
769
773
|
const stats = { copied: 0 };
|
|
770
|
-
|
|
771
|
-
|
|
774
|
+
let values = null;
|
|
775
|
+
if (substitute) {
|
|
776
|
+
if (!substitute.appId || !substitute.communityId) {
|
|
777
|
+
throw new Error(
|
|
778
|
+
'copyScaffold: substitute requires both appId and communityId. Refusing to resolve a ' +
|
|
779
|
+
'scaffold against a partial identity — a half-substituted scaffold is harder to spot ' +
|
|
780
|
+
'than an unsubstituted one.'
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
values = {
|
|
784
|
+
APP_ID: substitute.appId,
|
|
785
|
+
COMMUNITY_ID: substitute.communityId,
|
|
786
|
+
APP_NAME: substitute.appName || substitute.appId,
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const unresolved = [];
|
|
791
|
+
await copyDir(scaffoldDir, targetDir, stats, verbose, values, unresolved);
|
|
792
|
+
|
|
793
|
+
// FAIL LOUD. A placeholder that survives the copy reaches the developer as literal braces, and
|
|
794
|
+
// that is exactly the class of defect this substitution exists to end — so it must never be a
|
|
795
|
+
// warning. Name every offender so a new token added to a template cannot ship unnoticed.
|
|
796
|
+
if (unresolved.length > 0) {
|
|
797
|
+
throw new Error(
|
|
798
|
+
`Scaffold "${scaffoldType}" has ${unresolved.length} unresolved placeholder(s): ` +
|
|
799
|
+
`${unresolved.join(', ')}. Every {{TOKEN}} in a scaffold needs a value in copyScaffold's ` +
|
|
800
|
+
`substitute map (see templates/scaffolds/README.md for the documented set).`
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
|
|
772
804
|
return stats;
|
|
773
805
|
}
|
|
774
806
|
|
|
@@ -785,21 +817,66 @@ async function hasContentInDir(dirPath) {
|
|
|
785
817
|
}
|
|
786
818
|
|
|
787
819
|
/**
|
|
788
|
-
*
|
|
820
|
+
* THE ONE OWNER of scaffold placeholder substitution.
|
|
821
|
+
*
|
|
822
|
+
* templates/scaffolds/README.md has documented this contract all along — "Scaffolds use these
|
|
823
|
+
* placeholders (replaced during copy): {{APP_NAME}}, {{APP_ID}}, {{COMMUNITY_ID}}" — but nothing
|
|
824
|
+
* ever performed the replacement. Both scaffolds shipped their placeholders and their sample
|
|
825
|
+
* literals verbatim, so a developer's site was titled `{{APP_NAME}}` and the sample handler
|
|
826
|
+
* queried a community that is not theirs.
|
|
827
|
+
*
|
|
828
|
+
* SUBSTITUTION, NOT A SECOND LITERAL. The fix resolves each token to the caller's real values;
|
|
829
|
+
* it does not swap one hardcoded community for another. That is what lets the same scaffold serve
|
|
830
|
+
* every community without the CLI knowing any of their names.
|
|
831
|
+
*
|
|
832
|
+
* @param {string} text - file contents
|
|
833
|
+
* @param {Record<string,string>} values - token name (without braces) -> replacement
|
|
834
|
+
* @returns {string}
|
|
789
835
|
*/
|
|
790
|
-
|
|
836
|
+
function applyScaffoldTokens(text, values) {
|
|
837
|
+
return text.replace(/\{\{([A-Z_]+)\}\}/g, (whole, token) =>
|
|
838
|
+
Object.prototype.hasOwnProperty.call(values, token) ? values[token] : whole
|
|
839
|
+
);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/** A NUL byte is the cheap, dependency-free binary tell. Never rewrite bytes we cannot read. */
|
|
843
|
+
function looksBinary(buf) {
|
|
844
|
+
return buf.includes(0);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Recursively copy a directory, optionally substituting scaffold tokens on the way.
|
|
849
|
+
*
|
|
850
|
+
* @param {Record<string,string>|null} values - when null, the copy is byte-for-byte verbatim
|
|
851
|
+
* @param {string[]} unresolved - collects `file:{{TOKEN}}` for tokens no value was supplied for
|
|
852
|
+
*/
|
|
853
|
+
async function copyDir(src, dest, stats, verbose, values = null, unresolved = [], relBase = '') {
|
|
791
854
|
await fs.mkdir(dest, { recursive: true });
|
|
792
|
-
|
|
855
|
+
|
|
793
856
|
const entries = await fs.readdir(src, { withFileTypes: true });
|
|
794
|
-
|
|
857
|
+
|
|
795
858
|
for (const entry of entries) {
|
|
796
859
|
const srcPath = path.join(src, entry.name);
|
|
797
860
|
const destPath = path.join(dest, entry.name);
|
|
798
|
-
|
|
861
|
+
const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
|
|
862
|
+
|
|
799
863
|
if (entry.isDirectory()) {
|
|
800
|
-
await copyDir(srcPath, destPath, stats, verbose);
|
|
864
|
+
await copyDir(srcPath, destPath, stats, verbose, values, unresolved, rel);
|
|
801
865
|
} else {
|
|
802
|
-
|
|
866
|
+
if (values) {
|
|
867
|
+
const buf = await fs.readFile(srcPath);
|
|
868
|
+
if (looksBinary(buf)) {
|
|
869
|
+
await fs.copyFile(srcPath, destPath);
|
|
870
|
+
} else {
|
|
871
|
+
const out = applyScaffoldTokens(buf.toString('utf8'), values);
|
|
872
|
+
// Anything still wearing braces had no value supplied. Collect it rather than shipping
|
|
873
|
+
// it: an unresolved placeholder reaching a developer is the defect this exists to end.
|
|
874
|
+
for (const m of out.matchAll(/\{\{([A-Z_]+)\}\}/g)) unresolved.push(`${rel}:{{${m[1]}}}`);
|
|
875
|
+
await fs.writeFile(destPath, out);
|
|
876
|
+
}
|
|
877
|
+
} else {
|
|
878
|
+
await fs.copyFile(srcPath, destPath);
|
|
879
|
+
}
|
|
803
880
|
stats.copied++;
|
|
804
881
|
if (verbose) console.log(` Copied: ${entry.name}`);
|
|
805
882
|
}
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import * as fs from 'fs/promises';
|
|
16
|
+
import { statSync, realpathSync } from 'fs';
|
|
16
17
|
import * as path from 'path';
|
|
17
18
|
import { execFileSync } from 'child_process';
|
|
18
19
|
// Canonical doc_class taxonomy lives in CorpusDenyLint — import it, never re-list.
|
|
@@ -110,6 +111,73 @@ export function detectRepoSlug(dir) {
|
|
|
110
111
|
}
|
|
111
112
|
}
|
|
112
113
|
|
|
114
|
+
/**
|
|
115
|
+
* THE ONE OWNER OF "WHICH REPOSITORY OWNS THIS FILE".
|
|
116
|
+
*
|
|
117
|
+
* A workspace is NOT one repository. This one crosses three kinds of boundary at once:
|
|
118
|
+
* the superrepo itself; git SUBMODULES (DeSciX/DeSciX_Cloud|Core|Powch are gitlinks —
|
|
119
|
+
* `git ls-tree HEAD DeSciX/` shows mode 160000); and SIBLING repos that are not submodules
|
|
120
|
+
* at all but independent checkouts the superrepo deliberately ignores (EGPT-research via
|
|
121
|
+
* .gitignore:9, FRAQTL — each with its own `.git` and its own `origin` remote).
|
|
122
|
+
*
|
|
123
|
+
* A REF HAS NO MEANING EXCEPT RELATIVE TO A REPOSITORY. Resolving one against the ambient
|
|
124
|
+
* workspace root is what made this gate refuse 596 files that ARE committed: the superrepo
|
|
125
|
+
* sees a submodule only as a gitlink commit, and an ignored sibling repo not at all.
|
|
126
|
+
* `git rev-parse --show-toplevel` walks UP from the path and stops at the FIRST enclosing
|
|
127
|
+
* repository — which is precisely the repository whose branches the source's `ref` names,
|
|
128
|
+
* and which settles submodule and sibling boundaries with ONE mechanism rather than two.
|
|
129
|
+
*
|
|
130
|
+
* This is NOT the question `detectRepoSlug` answers. That one is "what is this repository
|
|
131
|
+
* CALLED" (for the cross-repo self-naming refusal); this one is "where does it BEGIN", so a
|
|
132
|
+
* ref and a repo-relative path can be formed. Both live here so repository identity has a
|
|
133
|
+
* single home and no consumer re-derives either.
|
|
134
|
+
*
|
|
135
|
+
* @param {string} absolutePath - absolute path to a file or directory in the workspace
|
|
136
|
+
* @returns {{repoRoot: string, repoRelativePath: string}|null} null when the path lies in
|
|
137
|
+
* NO git repository at all — callers must fail loud, never assume a root.
|
|
138
|
+
*/
|
|
139
|
+
const OWNING_REPO_CACHE = new Map(); // real directory -> repoRoot. POSITIVE results only.
|
|
140
|
+
export function resolveOwningRepo(absolutePath) {
|
|
141
|
+
const abs = path.resolve(absolutePath);
|
|
142
|
+
// `--show-toplevel` needs a directory that exists, and a source may name a file. Split
|
|
143
|
+
// off the basename ONLY for a non-directory: for a DIRECTORY source, `path.dirname` would
|
|
144
|
+
// step over the very boundary being resolved (dirname of `DeSciX/DeSciX_Cloud` is the
|
|
145
|
+
// superrepo, which is the wrong answer).
|
|
146
|
+
let dir = abs, base = null;
|
|
147
|
+
try {
|
|
148
|
+
if (!statSync(abs).isDirectory()) { dir = path.dirname(abs); base = path.basename(abs); }
|
|
149
|
+
} catch {
|
|
150
|
+
dir = path.dirname(abs); base = path.basename(abs);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// BOTH SIDES MUST BE REAL PATHS OR THE SUBTRACTION IS GARBAGE. `git rev-parse
|
|
154
|
+
// --show-toplevel` always answers with symlinks resolved, so comparing it against a
|
|
155
|
+
// caller path that still contains one yields a `../../..`-riddled relative path that
|
|
156
|
+
// matches nothing. This is not a corner case: on macOS every temp dir is /var ->
|
|
157
|
+
// /private/var, and this repo's own dev flow serves the Core tree through a symlink.
|
|
158
|
+
let realDir;
|
|
159
|
+
try { realDir = realpathSync(dir); } catch { realDir = dir; }
|
|
160
|
+
|
|
161
|
+
let repoRoot = OWNING_REPO_CACHE.get(realDir);
|
|
162
|
+
if (repoRoot === undefined) {
|
|
163
|
+
try {
|
|
164
|
+
repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
|
165
|
+
cwd: realDir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
166
|
+
}).trim();
|
|
167
|
+
} catch {
|
|
168
|
+
return null; // No repository. NOT cached: a negative is not durable — the directory
|
|
169
|
+
} // may be `git init`-ed later in the same process (fixtures do exactly this).
|
|
170
|
+
if (!repoRoot) return null;
|
|
171
|
+
OWNING_REPO_CACHE.set(realDir, repoRoot);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const repoRelativeDir = path.relative(repoRoot, realDir);
|
|
175
|
+
return {
|
|
176
|
+
repoRoot,
|
|
177
|
+
repoRelativePath: base ? path.join(repoRelativeDir, base) : repoRelativeDir,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
113
181
|
/**
|
|
114
182
|
* Enforce the two-shape source contract on ONE source entry.
|
|
115
183
|
*
|