@gmickel/gno 1.24.0 → 1.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -4
- package/assets/skill/SKILL.md +40 -17
- package/assets/skill/recipes/capture-and-file.md +20 -5
- package/browser-extension/artifacts/gno-browser-clipper-v1.26.0.zip +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.26.0.zip.sha256 +1 -0
- package/browser-extension/dist/PRIVACY.md +55 -0
- package/browser-extension/dist/chunk-vn5f663b.js +50 -0
- package/browser-extension/dist/chunk-ydfx5d7p.css +1 -0
- package/browser-extension/dist/content.js +1 -0
- package/browser-extension/dist/manifest.json +25 -0
- package/browser-extension/dist/preview.html +13 -0
- package/browser-extension/dist/service-worker.js +40 -0
- package/package.json +13 -3
- package/spec/cli.md +196 -4
- package/spec/db/schema.sql +102 -1
- package/spec/mcp.md +10 -0
- package/spec/output-schemas/browser-clip-preview.schema.json +83 -0
- package/spec/output-schemas/browser-clip.schema.json +586 -0
- package/spec/output-schemas/capture-receipt.schema.json +22 -1
- package/spec/output-schemas/clipper-csrf.schema.json +12 -0
- package/spec/output-schemas/clipper-error.schema.json +46 -0
- package/spec/output-schemas/clipper-pair-approval.schema.json +17 -0
- package/spec/output-schemas/clipper-pair-start.schema.json +26 -0
- package/spec/output-schemas/clipper-pair-status.schema.json +46 -0
- package/spec/output-schemas/clipper-revoke.schema.json +28 -0
- package/spec/output-schemas/mcp-capture-result.schema.json +12 -1
- package/spec/output-schemas/project-profile-apply.schema.json +209 -0
- package/spec/output-schemas/project-profile-command.schema.json +160 -0
- package/spec/output-schemas/query-diagnose.schema.json +7 -1
- package/spec/output-schemas/setup-profile-result.schema.json +87 -0
- package/spec/project-profile.schema.json +301 -0
- package/src/cli/commands/collection/add.ts +39 -45
- package/src/cli/commands/collection/remove.ts +28 -28
- package/src/cli/commands/collection/rename.ts +55 -73
- package/src/cli/commands/context/add.ts +37 -26
- package/src/cli/commands/context/rm.ts +47 -20
- package/src/cli/commands/init.ts +55 -125
- package/src/cli/commands/models/use.ts +43 -38
- package/src/cli/commands/profile-apply.ts +334 -0
- package/src/cli/commands/profile.ts +409 -0
- package/src/cli/commands/setup-activation.ts +205 -54
- package/src/cli/commands/setup-profile.ts +223 -0
- package/src/cli/commands/setup.ts +3 -0
- package/src/cli/program.ts +110 -9
- package/src/config/index.ts +3 -0
- package/src/config/project-profile.ts +367 -0
- package/src/config/saver.ts +16 -7
- package/src/config/types.ts +42 -0
- package/src/core/browser-clip-provenance.ts +139 -0
- package/src/core/browser-clip.ts +473 -0
- package/src/core/capture-write.ts +5 -0
- package/src/core/capture.ts +75 -18
- package/src/core/config-mutation.ts +138 -76
- package/src/core/config-write-lock.ts +89 -0
- package/src/core/context-identity.ts +16 -0
- package/src/core/context-resolver.ts +2 -12
- package/src/core/file-lock.ts +20 -6
- package/src/core/folder-setup-planning.ts +6 -21
- package/src/core/folder-setup.ts +30 -2
- package/src/core/path-rules.ts +53 -0
- package/src/core/project-affinity-surface.ts +102 -7
- package/src/core/project-profile-apply-state.ts +268 -0
- package/src/core/project-profile-apply-validation.ts +95 -0
- package/src/core/project-profile-apply.ts +408 -0
- package/src/core/project-profile-canonical.ts +71 -0
- package/src/core/project-profile-diff.ts +302 -0
- package/src/core/project-profile-discovery.ts +519 -0
- package/src/core/project-profile-file.ts +37 -0
- package/src/core/project-profile-parser.ts +98 -0
- package/src/core/project-profile.ts +490 -0
- package/src/ingestion/walker.ts +85 -44
- package/src/llm/cache.ts +21 -0
- package/src/serve/capture-service.ts +420 -0
- package/src/serve/clipper-body.ts +62 -0
- package/src/serve/clipper-capture.ts +248 -0
- package/src/serve/clipper-contract.ts +57 -0
- package/src/serve/clipper-idempotency.ts +35 -0
- package/src/serve/clipper-pairing.ts +297 -0
- package/src/serve/clipper-security-errors.ts +23 -0
- package/src/serve/clipper-security.ts +449 -0
- package/src/serve/config-sync.ts +2 -2
- package/src/serve/public/app.tsx +8 -1
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/public/index.html +1 -0
- package/src/serve/public/lib/clipper-approval.ts +206 -0
- package/src/serve/public/pages/ClipperPairing.tsx +210 -0
- package/src/serve/resident-runtime.ts +1 -0
- package/src/serve/routes/api.ts +20 -115
- package/src/serve/routes/clipper.ts +394 -0
- package/src/serve/server.ts +22 -0
- package/src/store/migrations/020-browser-clipper-security.ts +128 -0
- package/src/store/migrations/021-multi-context-identity.ts +37 -0
- package/src/store/migrations/index.ts +4 -0
- package/src/store/sqlite/adapter.ts +83 -0
- package/src/store/sqlite/clipper-store-types.ts +104 -0
- package/src/store/sqlite/clipper-store.ts +496 -0
|
@@ -24,13 +24,7 @@ import { isCanonicalPathContained, validateCollectionRoot } from "./validation";
|
|
|
24
24
|
|
|
25
25
|
const INVALID_NAME_CHARS = /[^a-z0-9_-]/g;
|
|
26
26
|
const LEADING_NON_ALPHANUMERIC = /^[^a-z0-9]+/;
|
|
27
|
-
|
|
28
|
-
/^\.env(?:\.|$)/,
|
|
29
|
-
/^credentials?(?:\.|$)/,
|
|
30
|
-
/^id_(?:rsa|dsa|ecdsa|ed25519)(?:\.|$)/,
|
|
31
|
-
/^secrets?(?:\.|$)/,
|
|
32
|
-
/\.(?:key|pem|p12|pfx)$/i,
|
|
33
|
-
];
|
|
27
|
+
import { hasLikelySecretPath, matchesCollectionExclusion } from "./path-rules";
|
|
34
28
|
|
|
35
29
|
export type FolderSetupErrorCode =
|
|
36
30
|
| "folder_not_found"
|
|
@@ -110,18 +104,6 @@ export function setupInjectedFailure(checkpoint: string): FolderSetupError {
|
|
|
110
104
|
);
|
|
111
105
|
}
|
|
112
106
|
|
|
113
|
-
function isExcluded(relPath: string, excludes: string[]): boolean {
|
|
114
|
-
const parts = relPath.split("/");
|
|
115
|
-
return excludes.some(
|
|
116
|
-
(exclude) => parts.includes(exclude) || relPath.startsWith(`${exclude}/`)
|
|
117
|
-
);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function hasSecretRisk(relPath: string): boolean {
|
|
121
|
-
const fileName = basename(relPath).toLowerCase();
|
|
122
|
-
return SECRET_FILE_PATTERNS.some((pattern) => pattern.test(fileName));
|
|
123
|
-
}
|
|
124
|
-
|
|
125
107
|
async function listFolderFiles(folder: string): Promise<string[]> {
|
|
126
108
|
const files: string[] = [];
|
|
127
109
|
const glob = new Bun.Glob("**/*");
|
|
@@ -345,7 +327,7 @@ export async function preflightFolder(
|
|
|
345
327
|
let files: string[];
|
|
346
328
|
try {
|
|
347
329
|
files = (await listFolderFiles(folder)).filter(
|
|
348
|
-
(path) => !
|
|
330
|
+
(path) => !matchesCollectionExclusion(path, excludes)
|
|
349
331
|
);
|
|
350
332
|
} catch {
|
|
351
333
|
return setupError(
|
|
@@ -361,7 +343,10 @@ export async function preflightFolder(
|
|
|
361
343
|
"Add supported documents or choose another folder."
|
|
362
344
|
);
|
|
363
345
|
}
|
|
364
|
-
if (
|
|
346
|
+
if (
|
|
347
|
+
!secretRiskAuthorized &&
|
|
348
|
+
files.some((path) => hasLikelySecretPath(path))
|
|
349
|
+
) {
|
|
365
350
|
return setupError(
|
|
366
351
|
"secret_risk",
|
|
367
352
|
`Folder contains likely credential or secret files: ${folder}`,
|
package/src/core/folder-setup.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { DEFAULT_EXCLUDES, loadConfig } from "../config";
|
|
|
12
12
|
import { defaultSyncService, withContentTypeRules } from "../ingestion";
|
|
13
13
|
import { verifyLexicalActivation } from "./activation-verifier";
|
|
14
14
|
import { applyConfigChange } from "./config-mutation";
|
|
15
|
+
import { getConfigWriteLockPath } from "./config-write-lock";
|
|
15
16
|
import {
|
|
16
17
|
type CollectionSelection,
|
|
17
18
|
type FolderSetupError,
|
|
@@ -60,6 +61,11 @@ export interface FolderSetupOptions {
|
|
|
60
61
|
name?: string;
|
|
61
62
|
exclude?: string[];
|
|
62
63
|
secretRiskAuthorized?: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* Preserve store-only recovery state while projecting the selected
|
|
66
|
+
* collection. Project-profile setup uses this after its own additive apply.
|
|
67
|
+
*/
|
|
68
|
+
additiveStoreProjection?: boolean;
|
|
63
69
|
/** Test-only deterministic interruption hook. */
|
|
64
70
|
failureInjection?: FolderSetupFailurePoint;
|
|
65
71
|
/** Test-only concurrency seam before the serialized config boundary. */
|
|
@@ -149,7 +155,7 @@ export async function setupFolder(
|
|
|
149
155
|
indexName: storeIdentity.indexName,
|
|
150
156
|
folderRealpath: folder,
|
|
151
157
|
});
|
|
152
|
-
const configLockPath =
|
|
158
|
+
const configLockPath = await getConfigWriteLockPath(options.configPath);
|
|
153
159
|
const unsafeOutput = await validateSetupOutputPaths(folder, [
|
|
154
160
|
{ label: "Data directory", path: options.dataDir },
|
|
155
161
|
{ label: "Setup receipt", path: receiptPath },
|
|
@@ -250,7 +256,6 @@ export async function setupFolder(
|
|
|
250
256
|
{
|
|
251
257
|
store: options.store,
|
|
252
258
|
configPath: options.configPath,
|
|
253
|
-
writeLockPath: configLockPath,
|
|
254
259
|
onConfigUpdated: (config) => {
|
|
255
260
|
activeConfig = config;
|
|
256
261
|
},
|
|
@@ -292,6 +297,29 @@ export async function setupFolder(
|
|
|
292
297
|
throw new SetupAbort(storeReceiptFailure);
|
|
293
298
|
}
|
|
294
299
|
},
|
|
300
|
+
projectStore: options.additiveStoreProjection
|
|
301
|
+
? async (store, config) => {
|
|
302
|
+
const selected = activeSelection;
|
|
303
|
+
if (!selected) {
|
|
304
|
+
return {
|
|
305
|
+
ok: false,
|
|
306
|
+
error: "Setup collection selection was not established",
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
const collectionResult = await store.upsertCollections([
|
|
310
|
+
selected.collection,
|
|
311
|
+
]);
|
|
312
|
+
if (!collectionResult.ok) {
|
|
313
|
+
return { ok: false, error: collectionResult.error.message };
|
|
314
|
+
}
|
|
315
|
+
const contextResult = await store.upsertContexts(
|
|
316
|
+
config.contexts ?? []
|
|
317
|
+
);
|
|
318
|
+
return contextResult.ok
|
|
319
|
+
? { ok: true }
|
|
320
|
+
: { ok: false, error: contextResult.error.message };
|
|
321
|
+
}
|
|
322
|
+
: undefined,
|
|
295
323
|
},
|
|
296
324
|
async (config) => {
|
|
297
325
|
const fresh = await selectFolderCollection(
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared path-rule semantics for profile validation, setup preflight, and
|
|
3
|
+
* ingestion. Paths use repository-relative POSIX form at this boundary.
|
|
4
|
+
*
|
|
5
|
+
* @module src/core/path-rules
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const GLOB_META_PATTERN = /[*?[\]{}]/;
|
|
9
|
+
const SECRET_FILE_PATTERNS = [
|
|
10
|
+
/^\.env(?:\.|$)/i,
|
|
11
|
+
/^credentials?(?:\.|$)/i,
|
|
12
|
+
/^id_(?:rsa|dsa|ecdsa|ed25519)(?:\.|$)/i,
|
|
13
|
+
/^secrets?(?:\.|$)/i,
|
|
14
|
+
/\.(?:key|pem|p12|pfx)$/i,
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export function hasLikelySecretPath(path: string): boolean {
|
|
18
|
+
const normalized = path.replaceAll("\\", "/");
|
|
19
|
+
const fileName = normalized.slice(normalized.lastIndexOf("/") + 1);
|
|
20
|
+
return SECRET_FILE_PATTERNS.some((pattern) => pattern.test(fileName));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function hasGlobMeta(pattern: string): boolean {
|
|
24
|
+
return GLOB_META_PATTERN.test(pattern);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Bare values preserve historical component/prefix semantics. Values with
|
|
29
|
+
* glob metacharacters match the complete normalized relative path.
|
|
30
|
+
*/
|
|
31
|
+
export function matchesCollectionExclusion(
|
|
32
|
+
relPath: string,
|
|
33
|
+
excludes: readonly string[]
|
|
34
|
+
): boolean {
|
|
35
|
+
const normalizedPath = relPath.replaceAll("\\", "/");
|
|
36
|
+
const parts = normalizedPath.split("/");
|
|
37
|
+
|
|
38
|
+
for (const rawPattern of excludes) {
|
|
39
|
+
const pattern = rawPattern.replaceAll("\\", "/");
|
|
40
|
+
if (hasGlobMeta(pattern)) {
|
|
41
|
+
if (new Bun.Glob(pattern).match(normalizedPath)) return true;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (
|
|
45
|
+
parts.includes(pattern) ||
|
|
46
|
+
normalizedPath === pattern ||
|
|
47
|
+
normalizedPath.startsWith(`${pattern}/`)
|
|
48
|
+
) {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
import type { Config, ProjectAffinityInput } from "../config/types";
|
|
11
11
|
import type { ProjectAffinityScoringInput } from "../pipeline/project-affinity";
|
|
12
12
|
|
|
13
|
+
import { PROJECT_AFFINITY_MAX_CONTRIBUTION } from "../config/types";
|
|
13
14
|
import { resolveProjectAffinity } from "./project-affinity";
|
|
15
|
+
import { compileProjectProfileYaml } from "./project-profile";
|
|
16
|
+
import { discoverProjectProfile } from "./project-profile-discovery";
|
|
14
17
|
|
|
15
18
|
export const MAX_PROJECT_AFFINITY_INPUTS = 16;
|
|
16
19
|
|
|
@@ -19,6 +22,12 @@ export interface CliProjectAffinityRequest {
|
|
|
19
22
|
projectRoots?: string[];
|
|
20
23
|
}
|
|
21
24
|
|
|
25
|
+
export interface ProjectProfileAffinityDefaults {
|
|
26
|
+
contribution: number;
|
|
27
|
+
enabled: boolean;
|
|
28
|
+
profileRoot: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
22
31
|
export class ProjectAffinityInputError extends Error {
|
|
23
32
|
constructor(message: string) {
|
|
24
33
|
super(message);
|
|
@@ -66,12 +75,54 @@ const scoringInput = async (
|
|
|
66
75
|
}),
|
|
67
76
|
});
|
|
68
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Resolve only the nearest trusted local profile's compiled affinity defaults.
|
|
80
|
+
* Invalid/missing profiles are a fallback signal, not a retrieval failure.
|
|
81
|
+
* Profile content never supplies identity: discovery's canonical profile root
|
|
82
|
+
* is the sole project root.
|
|
83
|
+
*/
|
|
84
|
+
export const resolveProjectProfileAffinityDefaults = async (
|
|
85
|
+
cwd: string,
|
|
86
|
+
config: Config
|
|
87
|
+
): Promise<ProjectProfileAffinityDefaults | null> => {
|
|
88
|
+
const discovery = await discoverProjectProfile({
|
|
89
|
+
channel: "local",
|
|
90
|
+
cwd,
|
|
91
|
+
});
|
|
92
|
+
if (
|
|
93
|
+
discovery.summary.status !== "found" ||
|
|
94
|
+
!discovery.profilePath ||
|
|
95
|
+
!discovery.profileRoot
|
|
96
|
+
) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const profileYaml = await Bun.file(discovery.profilePath).text();
|
|
102
|
+
const compiled = await compileProjectProfileYaml(profileYaml, {
|
|
103
|
+
profileRoot: discovery.profileRoot,
|
|
104
|
+
config,
|
|
105
|
+
});
|
|
106
|
+
if (!compiled.ok) return null;
|
|
107
|
+
return {
|
|
108
|
+
...compiled.value.desiredState.affinityDefaults,
|
|
109
|
+
profileRoot: discovery.profileRoot,
|
|
110
|
+
};
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
69
116
|
export const resolveCliProjectAffinity = async (
|
|
70
117
|
config: Config,
|
|
71
118
|
options: {
|
|
72
119
|
cwd: string;
|
|
73
120
|
disabled?: boolean;
|
|
74
121
|
projectRoots?: readonly string[];
|
|
122
|
+
resolveProfileDefaults?: (
|
|
123
|
+
cwd: string,
|
|
124
|
+
config: Config
|
|
125
|
+
) => Promise<ProjectProfileAffinityDefaults | null>;
|
|
75
126
|
}
|
|
76
127
|
): Promise<ProjectAffinityScoringInput | undefined> => {
|
|
77
128
|
const projectRoots = normalizeProjectAffinityValues(
|
|
@@ -83,16 +134,60 @@ export const resolveCliProjectAffinity = async (
|
|
|
83
134
|
"--no-project-affinity cannot be combined with --project-root"
|
|
84
135
|
);
|
|
85
136
|
}
|
|
86
|
-
if (options.disabled
|
|
137
|
+
if (options.disabled) return;
|
|
87
138
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
139
|
+
if (projectRoots.length > 0) {
|
|
140
|
+
return scoringInput(
|
|
141
|
+
{
|
|
142
|
+
roots: projectRoots.map((path) => ({
|
|
91
143
|
path,
|
|
92
144
|
source: "cli_explicit" as const,
|
|
93
|
-
}))
|
|
94
|
-
|
|
95
|
-
|
|
145
|
+
})),
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
...config,
|
|
149
|
+
projectAffinity: {
|
|
150
|
+
enabled: true,
|
|
151
|
+
contribution:
|
|
152
|
+
config.projectAffinity?.contribution ??
|
|
153
|
+
PROJECT_AFFINITY_MAX_CONTRIBUTION,
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
"local"
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const profileDefaults = await (
|
|
161
|
+
options.resolveProfileDefaults ?? resolveProjectProfileAffinityDefaults
|
|
162
|
+
)(options.cwd, config);
|
|
163
|
+
if (profileDefaults) {
|
|
164
|
+
if (!profileDefaults.enabled) return;
|
|
165
|
+
return scoringInput(
|
|
166
|
+
{
|
|
167
|
+
roots: [
|
|
168
|
+
{
|
|
169
|
+
path: profileDefaults.profileRoot,
|
|
170
|
+
source: "project_profile",
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
...config,
|
|
176
|
+
projectAffinity: {
|
|
177
|
+
enabled: profileDefaults.enabled,
|
|
178
|
+
contribution: profileDefaults.contribution,
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
"local"
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (config.projectAffinity?.enabled === false) return;
|
|
186
|
+
return scoringInput(
|
|
187
|
+
{ roots: [{ path: options.cwd, source: "cli_cwd" }] },
|
|
188
|
+
config,
|
|
189
|
+
"local"
|
|
190
|
+
);
|
|
96
191
|
};
|
|
97
192
|
|
|
98
193
|
export const resolveRemoteProjectAffinity = async (
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure create/update-only config projection for project profile apply.
|
|
3
|
+
*
|
|
4
|
+
* @module src/core/project-profile-apply-state
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Config, ProjectProfileBinding } from "../config/types";
|
|
8
|
+
import type { ProjectProfileDesiredState } from "./project-profile";
|
|
9
|
+
import type { ProjectProfileDiff } from "./project-profile-diff";
|
|
10
|
+
|
|
11
|
+
import { getPreset } from "../llm/registry";
|
|
12
|
+
import { normalizePersistedContextText } from "./context-identity";
|
|
13
|
+
import {
|
|
14
|
+
canonicalProjectProfileJson,
|
|
15
|
+
projectProfileIncludePattern,
|
|
16
|
+
} from "./project-profile";
|
|
17
|
+
|
|
18
|
+
export type ProjectProfileApplyDisposition =
|
|
19
|
+
| "created"
|
|
20
|
+
| "reused"
|
|
21
|
+
| "updated"
|
|
22
|
+
| "skipped";
|
|
23
|
+
|
|
24
|
+
export interface ProjectProfileApplyResource {
|
|
25
|
+
kind:
|
|
26
|
+
| "capability"
|
|
27
|
+
| "collection"
|
|
28
|
+
| "content_type"
|
|
29
|
+
| "contexts"
|
|
30
|
+
| "profile_binding"
|
|
31
|
+
| "project_affinity"
|
|
32
|
+
| "stale_mapping";
|
|
33
|
+
id: string;
|
|
34
|
+
disposition: ProjectProfileApplyDisposition;
|
|
35
|
+
pendingIndexing: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const compareCodeUnits = (left: string, right: string): number =>
|
|
39
|
+
left < right ? -1 : left > right ? 1 : 0;
|
|
40
|
+
|
|
41
|
+
export const canonicalProfileStateEqual = (
|
|
42
|
+
left: unknown,
|
|
43
|
+
right: unknown
|
|
44
|
+
): boolean =>
|
|
45
|
+
canonicalProjectProfileJson(left) === canonicalProjectProfileJson(right);
|
|
46
|
+
|
|
47
|
+
const collectionModels = (
|
|
48
|
+
config: Config,
|
|
49
|
+
presetId: string | undefined
|
|
50
|
+
): Config["collections"][number]["models"] | undefined => {
|
|
51
|
+
if (!presetId) return undefined;
|
|
52
|
+
const preset = getPreset(config, presetId);
|
|
53
|
+
if (!preset) return undefined;
|
|
54
|
+
return {
|
|
55
|
+
embed: preset.embed,
|
|
56
|
+
rerank: preset.rerank,
|
|
57
|
+
...(preset.expand ? { expand: preset.expand } : {}),
|
|
58
|
+
gen: preset.gen,
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export function applyProjectProfileDesiredState(
|
|
63
|
+
config: Config,
|
|
64
|
+
desired: ProjectProfileDesiredState,
|
|
65
|
+
collectionRoot: string,
|
|
66
|
+
profileBinding: ProjectProfileBinding
|
|
67
|
+
): Config {
|
|
68
|
+
const existingIndex = config.collections.findIndex(
|
|
69
|
+
(collection) => collection.name === desired.collection.name
|
|
70
|
+
);
|
|
71
|
+
const existing =
|
|
72
|
+
existingIndex >= 0 ? config.collections[existingIndex] : undefined;
|
|
73
|
+
const models = collectionModels(config, desired.collection.modelPreset);
|
|
74
|
+
const nextCollection: Config["collections"][number] = {
|
|
75
|
+
name: desired.collection.name,
|
|
76
|
+
path: collectionRoot,
|
|
77
|
+
pattern: projectProfileIncludePattern(desired.collection.include),
|
|
78
|
+
include: [],
|
|
79
|
+
exclude: desired.collection.exclude,
|
|
80
|
+
...(existing?.updateCmd ? { updateCmd: existing.updateCmd } : {}),
|
|
81
|
+
...(desired.collection.languageHint
|
|
82
|
+
? { languageHint: desired.collection.languageHint }
|
|
83
|
+
: existing?.languageHint
|
|
84
|
+
? { languageHint: existing.languageHint }
|
|
85
|
+
: {}),
|
|
86
|
+
...(models
|
|
87
|
+
? { models }
|
|
88
|
+
: existing?.models
|
|
89
|
+
? { models: existing.models }
|
|
90
|
+
: {}),
|
|
91
|
+
};
|
|
92
|
+
const collections = [...config.collections];
|
|
93
|
+
if (existingIndex >= 0) collections[existingIndex] = nextCollection;
|
|
94
|
+
else collections.push(nextCollection);
|
|
95
|
+
|
|
96
|
+
const contexts = [...config.contexts];
|
|
97
|
+
const configuredContexts = new Set(
|
|
98
|
+
contexts
|
|
99
|
+
.filter(
|
|
100
|
+
(context) =>
|
|
101
|
+
context.scopeType === "collection" &&
|
|
102
|
+
context.scopeKey === `${desired.collection.name}:`
|
|
103
|
+
)
|
|
104
|
+
.map((context) => normalizePersistedContextText(context.text))
|
|
105
|
+
);
|
|
106
|
+
for (const context of desired.contexts) {
|
|
107
|
+
const normalizedText = normalizePersistedContextText(context.text);
|
|
108
|
+
if (!configuredContexts.has(normalizedText)) {
|
|
109
|
+
contexts.push({
|
|
110
|
+
scopeType: context.scopeType,
|
|
111
|
+
scopeKey: context.scopeKey,
|
|
112
|
+
text: normalizedText,
|
|
113
|
+
});
|
|
114
|
+
configuredContexts.add(normalizedText);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const contentTypes = [...(config.contentTypes ?? [])];
|
|
119
|
+
for (const desiredRule of desired.contentTypes) {
|
|
120
|
+
const index = contentTypes.findIndex((rule) => rule.id === desiredRule.id);
|
|
121
|
+
if (index >= 0) contentTypes[index] = desiredRule;
|
|
122
|
+
else contentTypes.push(desiredRule);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const projectProfileBindings = [...(config.projectProfileBindings ?? [])];
|
|
126
|
+
const bindingIndex = projectProfileBindings.findIndex(
|
|
127
|
+
(binding) => binding.path === profileBinding.path
|
|
128
|
+
);
|
|
129
|
+
if (bindingIndex >= 0) projectProfileBindings[bindingIndex] = profileBinding;
|
|
130
|
+
else projectProfileBindings.push(profileBinding);
|
|
131
|
+
projectProfileBindings.sort((left, right) =>
|
|
132
|
+
compareCodeUnits(left.path, right.path)
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
...config,
|
|
137
|
+
collections,
|
|
138
|
+
contexts,
|
|
139
|
+
contentTypes,
|
|
140
|
+
projectProfileBindings,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function buildProjectProfileResources(
|
|
145
|
+
before: Config,
|
|
146
|
+
after: Config,
|
|
147
|
+
diff: ProjectProfileDiff,
|
|
148
|
+
desired: ProjectProfileDesiredState,
|
|
149
|
+
profileBinding: ProjectProfileBinding
|
|
150
|
+
): ProjectProfileApplyResource[] {
|
|
151
|
+
const resources: ProjectProfileApplyResource[] = [];
|
|
152
|
+
const beforeCollection = before.collections.find(
|
|
153
|
+
(collection) => collection.name === desired.collection.name
|
|
154
|
+
);
|
|
155
|
+
const afterCollection = after.collections.find(
|
|
156
|
+
(collection) => collection.name === desired.collection.name
|
|
157
|
+
);
|
|
158
|
+
resources.push({
|
|
159
|
+
kind: "collection",
|
|
160
|
+
id: desired.collection.name,
|
|
161
|
+
disposition: beforeCollection
|
|
162
|
+
? canonicalProfileStateEqual(beforeCollection, afterCollection)
|
|
163
|
+
? "reused"
|
|
164
|
+
: "updated"
|
|
165
|
+
: "created",
|
|
166
|
+
pendingIndexing: !canonicalProfileStateEqual(
|
|
167
|
+
beforeCollection,
|
|
168
|
+
afterCollection
|
|
169
|
+
),
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const beforeContextTexts = new Set(
|
|
173
|
+
before.contexts
|
|
174
|
+
.filter(
|
|
175
|
+
(context) =>
|
|
176
|
+
context.scopeType === "collection" &&
|
|
177
|
+
context.scopeKey === `${desired.collection.name}:`
|
|
178
|
+
)
|
|
179
|
+
.map((context) => normalizePersistedContextText(context.text))
|
|
180
|
+
);
|
|
181
|
+
const missingContexts = desired.contexts.filter(
|
|
182
|
+
(context) =>
|
|
183
|
+
!beforeContextTexts.has(normalizePersistedContextText(context.text))
|
|
184
|
+
);
|
|
185
|
+
resources.push({
|
|
186
|
+
kind: "contexts",
|
|
187
|
+
id: `${desired.collection.name}:`,
|
|
188
|
+
disposition:
|
|
189
|
+
desired.contexts.length === 0
|
|
190
|
+
? "skipped"
|
|
191
|
+
: missingContexts.length === 0
|
|
192
|
+
? "reused"
|
|
193
|
+
: beforeContextTexts.size === 0
|
|
194
|
+
? "created"
|
|
195
|
+
: "updated",
|
|
196
|
+
pendingIndexing: false,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const beforeContentTypes = new Map(
|
|
200
|
+
(before.contentTypes ?? []).map((rule) => [rule.id, rule])
|
|
201
|
+
);
|
|
202
|
+
for (const rule of desired.contentTypes) {
|
|
203
|
+
const previous = beforeContentTypes.get(rule.id);
|
|
204
|
+
const disposition = previous
|
|
205
|
+
? canonicalProfileStateEqual(previous, rule)
|
|
206
|
+
? "reused"
|
|
207
|
+
: "updated"
|
|
208
|
+
: "created";
|
|
209
|
+
resources.push({
|
|
210
|
+
kind: "content_type",
|
|
211
|
+
id: rule.id,
|
|
212
|
+
disposition,
|
|
213
|
+
pendingIndexing: disposition !== "reused",
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
if (desired.contentTypes.length === 0) {
|
|
217
|
+
resources.push({
|
|
218
|
+
kind: "content_type",
|
|
219
|
+
id: "(none declared)",
|
|
220
|
+
disposition: "skipped",
|
|
221
|
+
pendingIndexing: false,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
resources.push({
|
|
226
|
+
kind: "profile_binding",
|
|
227
|
+
id: profileBinding.collection,
|
|
228
|
+
disposition: (() => {
|
|
229
|
+
const previous = before.projectProfileBindings?.find(
|
|
230
|
+
(binding) => binding.path === profileBinding.path
|
|
231
|
+
);
|
|
232
|
+
if (!previous) return "created";
|
|
233
|
+
return canonicalProfileStateEqual(previous, profileBinding)
|
|
234
|
+
? "reused"
|
|
235
|
+
: "updated";
|
|
236
|
+
})(),
|
|
237
|
+
pendingIndexing: false,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
resources.push({
|
|
241
|
+
kind: "project_affinity",
|
|
242
|
+
id: "profile",
|
|
243
|
+
disposition: "skipped",
|
|
244
|
+
pendingIndexing: false,
|
|
245
|
+
});
|
|
246
|
+
for (const capability of desired.recommendedCapabilities) {
|
|
247
|
+
resources.push({
|
|
248
|
+
kind: "capability",
|
|
249
|
+
id: capability,
|
|
250
|
+
disposition: "skipped",
|
|
251
|
+
pendingIndexing: false,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
for (const mapping of diff.staleMappings) {
|
|
255
|
+
resources.push({
|
|
256
|
+
kind: "stale_mapping",
|
|
257
|
+
id: mapping.collection,
|
|
258
|
+
disposition: "skipped",
|
|
259
|
+
pendingIndexing: false,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
return resources.sort(
|
|
263
|
+
(left, right) =>
|
|
264
|
+
compareCodeUnits(left.kind, right.kind) ||
|
|
265
|
+
compareCodeUnits(left.id, right.id) ||
|
|
266
|
+
compareCodeUnits(left.disposition, right.disposition)
|
|
267
|
+
);
|
|
268
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime validation for project-profile apply receipts crossing command
|
|
3
|
+
* composition boundaries.
|
|
4
|
+
*
|
|
5
|
+
* @module src/core/project-profile-apply-validation
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
|
|
10
|
+
import type { ProjectProfileApplyReceipt } from "./project-profile-apply";
|
|
11
|
+
|
|
12
|
+
const diagnosticSchema = z
|
|
13
|
+
.object({
|
|
14
|
+
code: z.literal("STALE_PROFILE_MAPPING"),
|
|
15
|
+
severity: z.literal("warning"),
|
|
16
|
+
path: z.string(),
|
|
17
|
+
message: z.string().min(1),
|
|
18
|
+
remediation: z.string().min(1),
|
|
19
|
+
})
|
|
20
|
+
.strict();
|
|
21
|
+
|
|
22
|
+
const diffSchema = z
|
|
23
|
+
.object({
|
|
24
|
+
status: z.enum(["in_sync", "changes_required"]),
|
|
25
|
+
changes: z.array(
|
|
26
|
+
z
|
|
27
|
+
.object({
|
|
28
|
+
action: z.enum(["add", "update", "repair", "review"]),
|
|
29
|
+
field: z.string().min(1),
|
|
30
|
+
destructive: z.literal(false),
|
|
31
|
+
summary: z.string().min(1),
|
|
32
|
+
})
|
|
33
|
+
.strict()
|
|
34
|
+
),
|
|
35
|
+
staleMappings: z.array(
|
|
36
|
+
z
|
|
37
|
+
.object({
|
|
38
|
+
collection: z.string().min(1),
|
|
39
|
+
reason: z.enum(["name_changed", "root_changed"]),
|
|
40
|
+
choices: z.tuple([
|
|
41
|
+
z.literal("repair"),
|
|
42
|
+
z.literal("remove_explicitly"),
|
|
43
|
+
]),
|
|
44
|
+
})
|
|
45
|
+
.strict()
|
|
46
|
+
),
|
|
47
|
+
})
|
|
48
|
+
.strict();
|
|
49
|
+
|
|
50
|
+
const resourceSchema = z
|
|
51
|
+
.object({
|
|
52
|
+
kind: z.enum([
|
|
53
|
+
"capability",
|
|
54
|
+
"collection",
|
|
55
|
+
"content_type",
|
|
56
|
+
"contexts",
|
|
57
|
+
"profile_binding",
|
|
58
|
+
"project_affinity",
|
|
59
|
+
"stale_mapping",
|
|
60
|
+
]),
|
|
61
|
+
id: z.string().min(1),
|
|
62
|
+
disposition: z.enum(["created", "reused", "updated", "skipped"]),
|
|
63
|
+
pendingIndexing: z.boolean(),
|
|
64
|
+
})
|
|
65
|
+
.strict();
|
|
66
|
+
|
|
67
|
+
export const projectProfileApplyReceiptSchema: z.ZodType<ProjectProfileApplyReceipt> =
|
|
68
|
+
z
|
|
69
|
+
.object({
|
|
70
|
+
schemaVersion: z.literal("1.0"),
|
|
71
|
+
command: z.literal("apply"),
|
|
72
|
+
status: z.enum(["applied", "unchanged"]),
|
|
73
|
+
profile: z
|
|
74
|
+
.object({
|
|
75
|
+
fingerprint: z.string().regex(/^[a-f0-9]{64}$/),
|
|
76
|
+
})
|
|
77
|
+
.strict(),
|
|
78
|
+
diff: diffSchema,
|
|
79
|
+
resources: z.array(resourceSchema),
|
|
80
|
+
pendingIndexing: z.array(z.string().min(1)),
|
|
81
|
+
diagnostics: z.array(diagnosticSchema),
|
|
82
|
+
})
|
|
83
|
+
.strict()
|
|
84
|
+
.refine(
|
|
85
|
+
(receipt) =>
|
|
86
|
+
new Set(receipt.pendingIndexing).size ===
|
|
87
|
+
receipt.pendingIndexing.length,
|
|
88
|
+
{ path: ["pendingIndexing"], message: "Entries must be unique." }
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
export function isProjectProfileApplyReceipt(
|
|
92
|
+
value: unknown
|
|
93
|
+
): value is ProjectProfileApplyReceipt {
|
|
94
|
+
return projectProfileApplyReceiptSchema.safeParse(value).success;
|
|
95
|
+
}
|