@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.
Files changed (96) hide show
  1. package/README.md +18 -4
  2. package/assets/skill/SKILL.md +40 -17
  3. package/assets/skill/recipes/capture-and-file.md +20 -5
  4. package/browser-extension/artifacts/gno-browser-clipper-v1.26.0.zip +0 -0
  5. package/browser-extension/artifacts/gno-browser-clipper-v1.26.0.zip.sha256 +1 -0
  6. package/browser-extension/dist/PRIVACY.md +55 -0
  7. package/browser-extension/dist/chunk-vn5f663b.js +50 -0
  8. package/browser-extension/dist/chunk-ydfx5d7p.css +1 -0
  9. package/browser-extension/dist/content.js +1 -0
  10. package/browser-extension/dist/manifest.json +25 -0
  11. package/browser-extension/dist/preview.html +13 -0
  12. package/browser-extension/dist/service-worker.js +40 -0
  13. package/package.json +13 -3
  14. package/spec/cli.md +196 -4
  15. package/spec/db/schema.sql +102 -1
  16. package/spec/mcp.md +10 -0
  17. package/spec/output-schemas/browser-clip-preview.schema.json +83 -0
  18. package/spec/output-schemas/browser-clip.schema.json +586 -0
  19. package/spec/output-schemas/capture-receipt.schema.json +22 -1
  20. package/spec/output-schemas/clipper-csrf.schema.json +12 -0
  21. package/spec/output-schemas/clipper-error.schema.json +46 -0
  22. package/spec/output-schemas/clipper-pair-approval.schema.json +17 -0
  23. package/spec/output-schemas/clipper-pair-start.schema.json +26 -0
  24. package/spec/output-schemas/clipper-pair-status.schema.json +46 -0
  25. package/spec/output-schemas/clipper-revoke.schema.json +28 -0
  26. package/spec/output-schemas/mcp-capture-result.schema.json +12 -1
  27. package/spec/output-schemas/project-profile-apply.schema.json +209 -0
  28. package/spec/output-schemas/project-profile-command.schema.json +160 -0
  29. package/spec/output-schemas/query-diagnose.schema.json +7 -1
  30. package/spec/output-schemas/setup-profile-result.schema.json +87 -0
  31. package/spec/project-profile.schema.json +301 -0
  32. package/src/cli/commands/collection/add.ts +39 -45
  33. package/src/cli/commands/collection/remove.ts +28 -28
  34. package/src/cli/commands/collection/rename.ts +55 -73
  35. package/src/cli/commands/context/add.ts +37 -26
  36. package/src/cli/commands/context/rm.ts +47 -20
  37. package/src/cli/commands/init.ts +55 -125
  38. package/src/cli/commands/models/use.ts +43 -38
  39. package/src/cli/commands/profile-apply.ts +334 -0
  40. package/src/cli/commands/profile.ts +409 -0
  41. package/src/cli/commands/setup-activation.ts +205 -54
  42. package/src/cli/commands/setup-profile.ts +223 -0
  43. package/src/cli/commands/setup.ts +3 -0
  44. package/src/cli/program.ts +110 -9
  45. package/src/config/index.ts +3 -0
  46. package/src/config/project-profile.ts +367 -0
  47. package/src/config/saver.ts +16 -7
  48. package/src/config/types.ts +42 -0
  49. package/src/core/browser-clip-provenance.ts +139 -0
  50. package/src/core/browser-clip.ts +473 -0
  51. package/src/core/capture-write.ts +5 -0
  52. package/src/core/capture.ts +75 -18
  53. package/src/core/config-mutation.ts +138 -76
  54. package/src/core/config-write-lock.ts +89 -0
  55. package/src/core/context-identity.ts +16 -0
  56. package/src/core/context-resolver.ts +2 -12
  57. package/src/core/file-lock.ts +20 -6
  58. package/src/core/folder-setup-planning.ts +6 -21
  59. package/src/core/folder-setup.ts +30 -2
  60. package/src/core/path-rules.ts +53 -0
  61. package/src/core/project-affinity-surface.ts +102 -7
  62. package/src/core/project-profile-apply-state.ts +268 -0
  63. package/src/core/project-profile-apply-validation.ts +95 -0
  64. package/src/core/project-profile-apply.ts +408 -0
  65. package/src/core/project-profile-canonical.ts +71 -0
  66. package/src/core/project-profile-diff.ts +302 -0
  67. package/src/core/project-profile-discovery.ts +519 -0
  68. package/src/core/project-profile-file.ts +37 -0
  69. package/src/core/project-profile-parser.ts +98 -0
  70. package/src/core/project-profile.ts +490 -0
  71. package/src/ingestion/walker.ts +85 -44
  72. package/src/llm/cache.ts +21 -0
  73. package/src/serve/capture-service.ts +420 -0
  74. package/src/serve/clipper-body.ts +62 -0
  75. package/src/serve/clipper-capture.ts +248 -0
  76. package/src/serve/clipper-contract.ts +57 -0
  77. package/src/serve/clipper-idempotency.ts +35 -0
  78. package/src/serve/clipper-pairing.ts +297 -0
  79. package/src/serve/clipper-security-errors.ts +23 -0
  80. package/src/serve/clipper-security.ts +449 -0
  81. package/src/serve/config-sync.ts +2 -2
  82. package/src/serve/public/app.tsx +8 -1
  83. package/src/serve/public/globals.built.css +1 -1
  84. package/src/serve/public/index.html +1 -0
  85. package/src/serve/public/lib/clipper-approval.ts +206 -0
  86. package/src/serve/public/pages/ClipperPairing.tsx +210 -0
  87. package/src/serve/resident-runtime.ts +1 -0
  88. package/src/serve/routes/api.ts +20 -115
  89. package/src/serve/routes/clipper.ts +394 -0
  90. package/src/serve/server.ts +22 -0
  91. package/src/store/migrations/020-browser-clipper-security.ts +128 -0
  92. package/src/store/migrations/021-multi-context-identity.ts +37 -0
  93. package/src/store/migrations/index.ts +4 -0
  94. package/src/store/sqlite/adapter.ts +83 -0
  95. package/src/store/sqlite/clipper-store-types.ts +104 -0
  96. 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
- const SECRET_FILE_PATTERNS = [
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) => !isExcluded(path, excludes)
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 (!secretRiskAuthorized && files.some((path) => hasSecretRisk(path))) {
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}`,
@@ -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 = `${options.configPath}.setup.lock`;
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 || config.projectAffinity?.enabled === false) return;
137
+ if (options.disabled) return;
87
138
 
88
- const roots =
89
- projectRoots.length > 0
90
- ? projectRoots.map((path) => ({
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
- : [{ path: options.cwd, source: "cli_cwd" as const }];
95
- return scoringInput({ roots }, config, "local");
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
+ }