@checkstack/gitops-common 0.1.1 → 0.2.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,44 @@
1
1
  # @checkstack/gitops-common
2
2
 
3
+ ## 0.2.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [8d1ef12]
8
+ - @checkstack/common@0.7.0
9
+
10
+ ## 0.2.0
11
+
12
+ ### Minor Changes
13
+
14
+ - 8ef367a: Added `registerSpecSchemaDocumentation` to EntityKindRegistry to allow plugins to provide detailed JSON Schemas for specific configurations. The frontend now displays these registered schemas as dropdown alternatives, improving the developer experience when authoring GitOps configurations.
15
+ - cb65e9d: ### Schema-driven secret resolution, rotation invalidation, and security hardening
16
+
17
+ **Breaking**: Replaced `{ secretRef: "..." }` object syntax with `${{ secrets.NAME }}` template interpolation. The `secretField()`, `secretRefSchema`, `isSecretRef`, `SecretRef`, and `ResolvedSecretField` exports have been removed from `@checkstack/gitops-common`.
18
+
19
+ **Breaking**: `ReconcileContext.resolveSecretsBySchema()` now returns `{ resolved: T; warnings: string[] }` instead of `T` directly. Plugins must destructure the result. Warnings contain messages for `${{ secrets.NAME }}` templates found in non-secret fields (fields without `x-secret` annotation).
20
+
21
+ **New features**:
22
+
23
+ - Secrets can be referenced in **any string field** using `${{ secrets.NAME }}` syntax
24
+ - Inline interpolation is supported: `"postgres://user:${{ secrets.DB_PASS }}@host/db"`
25
+ - Resolution is **schema-driven** — reuses the existing `configString({ "x-secret": true })` pattern from DynamicForm
26
+ - Secret rotation now automatically invalidates affected entities, triggering re-reconciliation on the next sync cycle
27
+ - New `getSecretUsage` RPC endpoint to look up which entities reference a given secret
28
+ - Secrets UI now shows an expandable usage panel per secret showing referencing entities
29
+ - Reconciliation warnings: templates in non-secret fields are detected and surfaced in the provenance UI
30
+ - New `secretNameSchema` and `SECRET_NAME_REGEX` exports for validating secret names
31
+
32
+ **Security**:
33
+
34
+ - Secret names are validated at creation: must start with a letter, contain only `[a-zA-Z0-9_-]`, max 63 chars
35
+ - Secrets are validated to exist at sync time but **not pre-resolved** into the spec
36
+ - Templates in `metadata` fields are **rejected** to prevent secret leaks via display fields
37
+ - Only fields with `x-secret` schema annotations get resolved — no escape hatch
38
+ - Templates in non-secret fields emit warnings (stored in provenance, visible in UI) instead of silently passing
39
+
40
+ **Migration**: Update YAML descriptors to use `${{ secrets.NAME }}` instead of `secretRef: name`. Remove `secretField()` imports from plugin schemas — use `configString({ "x-secret": true })` to annotate secret fields. Destructure `const { resolved } = await context.resolveSecretsBySchema({ value, schema })` (return type changed from `T` to `{ resolved: T; warnings: string[] }`).
41
+
3
42
  ## 0.1.1
4
43
 
5
44
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/gitops-common",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
package/src/access.ts CHANGED
@@ -15,7 +15,7 @@ export const gitopsAccess = {
15
15
  },
16
16
  }),
17
17
 
18
- /** Secret management for secretRef values. */
18
+ /** Secret management for ${{ secrets.NAME }} template values. */
19
19
  secret: accessPair("secret", {
20
20
  read: {
21
21
  description: "View secret names (not values)",
@@ -25,6 +25,32 @@ export interface ReconcileContext {
25
25
  kind: string;
26
26
  entityName: string;
27
27
  }) => Promise<string | undefined>;
28
+ /**
29
+ * Schema-driven secret resolution.
30
+ *
31
+ * Walks the provided Zod schema to find fields annotated with
32
+ * `configString({ "x-secret": true })`, then resolves `${{ secrets.NAME }}`
33
+ * templates **only** in those fields. Non-secret fields are returned as-is.
34
+ *
35
+ * Returns warnings for any templates found in non-secret fields — these
36
+ * templates will NOT be resolved and should be surfaced to the user.
37
+ *
38
+ * Use the plugin's actual typed schema (e.g., the strategy config schema)
39
+ * rather than the generic GitOps spec schema — the typed schema carries the
40
+ * `x-secret` annotations.
41
+ *
42
+ * @example
43
+ * ```typescript
44
+ * const { resolved, warnings } = await context.resolveSecretsBySchema({
45
+ * value: entity.spec.config,
46
+ * schema: strategy.config.schema,
47
+ * });
48
+ * ```
49
+ */
50
+ resolveSecretsBySchema: <T>(params: {
51
+ value: T;
52
+ schema: z.ZodTypeAny;
53
+ }) => Promise<{ resolved: T; warnings: string[] }>;
28
54
  }
29
55
 
30
56
  /**
@@ -43,7 +69,7 @@ export interface EntityKindDefinition<TSpec = unknown> {
43
69
 
44
70
  /**
45
71
  * Called when an entity of this kind is discovered or updated via GitOps.
46
- * The entity's spec is fully validated and all secretRef values are resolved.
72
+ * The entity's spec is fully validated and all `${{ secrets.NAME }}` templates are resolved.
47
73
  *
48
74
  * Must return the plugin-specific entity ID (e.g., the catalog system UUID).
49
75
  * The reconciler engine stores this in provenance for generic frontend lookups.
@@ -101,6 +127,33 @@ export interface EntityKindExtensionDefinition<TExtensionSpec = unknown> {
101
127
  }) => Promise<void>;
102
128
  }
103
129
 
130
+ /**
131
+ * Definition for documenting a specific field variant within a kind's spec.
132
+ */
133
+ export interface SpecSchemaDocumentation {
134
+ /** Which field this documentation applies to (e.g., "config", "collectors[].config") */
135
+ fieldPath: string;
136
+ /**
137
+ * Optional unique identifier for this variant.
138
+ * Useful when other variants depend on this one being selected.
139
+ */
140
+ variantId?: string;
141
+ /** Human-readable label shown in the dropdown (e.g., "HTTP Strategy") */
142
+ label: string;
143
+ /** Optional markdown description shown alongside the schema */
144
+ description?: string;
145
+ /** The Zod schema for this variant */
146
+ schema: z.ZodTypeAny;
147
+ /**
148
+ * If specified, this variant will only be shown if the selected variant
149
+ * in the target fieldPath matches one of the provided variantIds.
150
+ */
151
+ conditions?: Array<{
152
+ fieldPath: string;
153
+ variantIds: string[];
154
+ }>;
155
+ }
156
+
104
157
  /**
105
158
  * The registry interface exposed via the Extension Point.
106
159
  * Plugins call these methods during their `register()` phase.
@@ -113,4 +166,12 @@ export interface EntityKindRegistry {
113
166
  registerKindExtension<TExtensionSpec>(
114
167
  definition: EntityKindExtensionDefinition<TExtensionSpec>,
115
168
  ): void;
169
+
170
+ /** Register documentation for a specific field variant within a kind's spec. */
171
+ registerSpecSchemaDocumentation(
172
+ params: {
173
+ apiVersion: string;
174
+ kind: string;
175
+ } & SpecSchemaDocumentation,
176
+ ): void;
116
177
  }
package/src/index.ts CHANGED
@@ -7,11 +7,11 @@ export {
7
7
  type EntityMetadata,
8
8
  } from "./entity-envelope";
9
9
  export {
10
- secretField,
11
- secretRefSchema,
12
- isSecretRef,
13
- type SecretRef,
14
- type ResolvedSecretField,
10
+ SECRET_NAME_REGEX,
11
+ SECRET_TEMPLATE_REGEX,
12
+ secretNameSchema,
13
+ secretTemplateSchema,
14
+ collectSecretNames,
15
15
  } from "./secret-field";
16
16
  export {
17
17
  entityRefSchema,
@@ -23,6 +23,7 @@ export {
23
23
  type EntityKindExtensionDefinition,
24
24
  type EntityKindRegistry,
25
25
  type ReconcileContext,
26
+ type SpecSchemaDocumentation,
26
27
  } from "./entity-kind-registry";
27
28
  export { gitopsAccess, gitopsAccessRules } from "./access";
28
29
  export { gitopsRoutes } from "./routes";
@@ -19,6 +19,8 @@ export const provenanceSchema = z.object({
19
19
  lastSyncHash: z.string(),
20
20
  status: provenanceStatusSchema,
21
21
  errorMessage: z.string().nullable(),
22
+ /** Warnings about unresolved secret templates in non-secret fields. */
23
+ warnings: z.array(z.string()),
22
24
  lastSyncedAt: z.date(),
23
25
  createdAt: z.date(),
24
26
  });
@@ -6,6 +6,7 @@ import {
6
6
  provenanceStatusSchema,
7
7
  deletionPolicySchema,
8
8
  } from "./provenance-types";
9
+ import { secretNameSchema } from "./secret-field";
9
10
  import { z } from "zod";
10
11
 
11
12
  export const gitopsContract = {
@@ -175,7 +176,7 @@ export const gitopsContract = {
175
176
  })
176
177
  .input(
177
178
  z.object({
178
- name: z.string().min(1).max(63),
179
+ name: secretNameSchema,
179
180
  value: z.string().min(1),
180
181
  description: z.string().optional(),
181
182
  }),
@@ -218,6 +219,24 @@ export const gitopsContract = {
218
219
  .input(z.object({ name: z.string() }))
219
220
  .output(z.object({ value: z.string() })),
220
221
 
222
+ /** Look up which entities reference a given secret. */
223
+ getSecretUsage: proc({
224
+ operationType: "query",
225
+ userType: "authenticated",
226
+ access: [gitopsAccess.secret.read],
227
+ })
228
+ .input(z.object({ secretName: z.string() }))
229
+ .output(
230
+ z.array(
231
+ z.object({
232
+ kind: z.string(),
233
+ entityName: z.string(),
234
+ repository: z.string(),
235
+ filePath: z.string(),
236
+ }),
237
+ ),
238
+ ),
239
+
221
240
  // ═══════════════════════════════════════════════════════════════════════════
222
241
  // KIND REGISTRY (browsing)
223
242
  // ═══════════════════════════════════════════════════════════════════════════
@@ -232,6 +251,7 @@ export const gitopsContract = {
232
251
  z.object({
233
252
  apiVersion: z.string(),
234
253
  kind: z.string(),
254
+ metadataSchema: z.record(z.string(), z.unknown()),
235
255
  specSchema: z.record(z.string(), z.unknown()),
236
256
  extensions: z.array(
237
257
  z.object({
@@ -239,6 +259,19 @@ export const gitopsContract = {
239
259
  specSchema: z.record(z.string(), z.unknown()),
240
260
  }),
241
261
  ),
262
+ specSchemaDocumentation: z.array(
263
+ z.object({
264
+ fieldPath: z.string(),
265
+ variantId: z.string().optional(),
266
+ label: z.string(),
267
+ description: z.string().optional(),
268
+ specSchema: z.record(z.string(), z.unknown()),
269
+ conditions: z.array(z.object({
270
+ fieldPath: z.string(),
271
+ variantIds: z.array(z.string()),
272
+ })).optional(),
273
+ }),
274
+ ),
242
275
  }),
243
276
  ),
244
277
  ),
@@ -1,50 +1,168 @@
1
1
  import { describe, it, expect } from "bun:test";
2
- import { secretField, isSecretRef, type SecretRef } from "./secret-field";
2
+ import {
3
+ SECRET_TEMPLATE_REGEX,
4
+ secretNameSchema,
5
+ collectSecretNames,
6
+ secretTemplateSchema,
7
+ } from "./secret-field";
3
8
 
4
- describe("secretField", () => {
5
- const schema = secretField();
9
+ describe("SECRET_TEMPLATE_REGEX", () => {
10
+ it("matches a simple secret template", () => {
11
+ SECRET_TEMPLATE_REGEX.lastIndex = 0;
12
+ const match = SECRET_TEMPLATE_REGEX.exec("${{ secrets.DB_PASS }}");
13
+ expect(match).not.toBeNull();
14
+ expect(match![1]).toBe("DB_PASS");
15
+ });
6
16
 
7
- it("accepts a plain string value", () => {
8
- const result = schema.safeParse("my-password");
9
- expect(result.success).toBe(true);
10
- if (result.success) expect(result.data).toBe("my-password");
17
+ it("matches templates with extra whitespace", () => {
18
+ SECRET_TEMPLATE_REGEX.lastIndex = 0;
19
+ const match = SECRET_TEMPLATE_REGEX.exec("${{ secrets.MY_KEY }}");
20
+ expect(match).not.toBeNull();
21
+ expect(match![1]).toBe("MY_KEY");
11
22
  });
12
23
 
13
- it("accepts a secretRef object", () => {
14
- const result = schema.safeParse({ secretRef: "prod-db-password" });
15
- expect(result.success).toBe(true);
16
- if (result.success) {
17
- expect(result.data).toEqual({ secretRef: "prod-db-password" });
24
+ it("matches hyphenated secret names", () => {
25
+ SECRET_TEMPLATE_REGEX.lastIndex = 0;
26
+ const match = SECRET_TEMPLATE_REGEX.exec("${{ secrets.prod-db-pass }}");
27
+ expect(match).not.toBeNull();
28
+ expect(match![1]).toBe("prod-db-pass");
29
+ });
30
+
31
+ it("matches multiple templates in one string", () => {
32
+ const str =
33
+ "postgres://${{ secrets.DB_USER }}:${{ secrets.DB_PASS }}@host/db";
34
+ const matches: string[] = [];
35
+ SECRET_TEMPLATE_REGEX.lastIndex = 0;
36
+ let match: RegExpExecArray | null;
37
+ while ((match = SECRET_TEMPLATE_REGEX.exec(str)) !== null) {
38
+ matches.push(match[1]);
18
39
  }
40
+ expect(matches).toEqual(["DB_USER", "DB_PASS"]);
19
41
  });
20
42
 
21
- it("rejects a number", () => {
22
- const result = schema.safeParse(42);
23
- expect(result.success).toBe(false);
43
+ it("does not match plain strings", () => {
44
+ SECRET_TEMPLATE_REGEX.lastIndex = 0;
45
+ expect(SECRET_TEMPLATE_REGEX.exec("just a string")).toBeNull();
24
46
  });
25
47
 
26
- it("rejects an object without secretRef", () => {
27
- const result = schema.safeParse({ key: "value" });
28
- expect(result.success).toBe(false);
48
+ it("does not match malformed templates", () => {
49
+ SECRET_TEMPLATE_REGEX.lastIndex = 0;
50
+ expect(SECRET_TEMPLATE_REGEX.exec("${ secrets.WRONG }")).toBeNull();
51
+ SECRET_TEMPLATE_REGEX.lastIndex = 0;
52
+ expect(SECRET_TEMPLATE_REGEX.exec("${{ notSecrets.WRONG }}")).toBeNull();
29
53
  });
54
+ });
30
55
 
31
- it("rejects secretRef with empty name", () => {
32
- const result = schema.safeParse({ secretRef: "" });
56
+ describe("secretTemplateSchema", () => {
57
+ it("accepts a valid template string", () => {
58
+ const result = secretTemplateSchema.safeParse("${{ secrets.MY_SECRET }}");
59
+ expect(result.success).toBe(true);
60
+ });
61
+
62
+ it("rejects a plain string without template", () => {
63
+ const result = secretTemplateSchema.safeParse("just-a-string");
33
64
  expect(result.success).toBe(false);
34
65
  });
35
66
  });
36
67
 
37
- describe("isSecretRef", () => {
38
- it("returns true for a SecretRef object", () => {
39
- const ref: SecretRef = { secretRef: "my-secret" };
40
- expect(isSecretRef(ref)).toBe(true);
68
+ describe("collectSecretNames", () => {
69
+ it("extracts a single secret from a top-level string", () => {
70
+ const result = collectSecretNames({
71
+ value: { password: "${{ secrets.DB_PASS }}" },
72
+ });
73
+ expect(result).toEqual(["DB_PASS"]);
74
+ });
75
+
76
+ it("returns empty array when no secrets are referenced", () => {
77
+ const result = collectSecretNames({
78
+ value: { host: "localhost", port: 5432 },
79
+ });
80
+ expect(result).toEqual([]);
81
+ });
82
+
83
+ it("extracts secrets from nested objects", () => {
84
+ const result = collectSecretNames({
85
+ value: {
86
+ connection: {
87
+ host: "db.internal",
88
+ password: "${{ secrets.NESTED_PASS }}",
89
+ },
90
+ },
91
+ });
92
+ expect(result).toEqual(["NESTED_PASS"]);
93
+ });
94
+
95
+ it("extracts secrets from arrays", () => {
96
+ const result = collectSecretNames({
97
+ value: {
98
+ credentials: [
99
+ { name: "db", secret: "${{ secrets.DB_PASS }}" },
100
+ { name: "api", secret: "${{ secrets.API_KEY }}" },
101
+ ],
102
+ },
103
+ });
104
+ expect(result).toEqual(["DB_PASS", "API_KEY"]);
105
+ });
106
+
107
+ it("extracts multiple secrets from a single interpolated string", () => {
108
+ const result = collectSecretNames({
109
+ value: {
110
+ url: "postgres://${{ secrets.DB_USER }}:${{ secrets.DB_PASS }}@host/db",
111
+ },
112
+ });
113
+ expect(result).toEqual(["DB_USER", "DB_PASS"]);
114
+ });
115
+
116
+ it("deduplicates secret names", () => {
117
+ const result = collectSecretNames({
118
+ value: {
119
+ primary: "${{ secrets.SHARED }}",
120
+ secondary: "${{ secrets.SHARED }}",
121
+ },
122
+ });
123
+ expect(result).toEqual(["SHARED"]);
124
+ });
125
+
126
+ it("handles null and undefined values gracefully", () => {
127
+ const result = collectSecretNames({
128
+ value: { a: null, b: undefined, c: "${{ secrets.VALID }}" },
129
+ });
130
+ expect(result).toEqual(["VALID"]);
131
+ });
132
+
133
+ it("ignores non-string primitives", () => {
134
+ const result = collectSecretNames({
135
+ value: { num: 42, bool: true, str: "${{ secrets.ONLY_THIS }}" },
136
+ });
137
+ expect(result).toEqual(["ONLY_THIS"]);
138
+ });
139
+ });
140
+
141
+ describe("secretNameSchema", () => {
142
+ it.each(["DB_PASS", "my-secret", "ApiKey123", "a", "prod_db_pass_v2"])("accepts valid name: %s", (name) => {
143
+ expect(secretNameSchema.safeParse(name).success).toBe(true);
144
+ });
145
+
146
+ it.each([
147
+ "has space",
148
+ "has\ttab",
149
+ "123starts-with-digit",
150
+ "has.dot",
151
+ "no@special",
152
+ "no/slashes",
153
+ "${{ secrets.NOPE }}",
154
+ "",
155
+ ])("rejects invalid name: %s", (name) => {
156
+ expect(secretNameSchema.safeParse(name).success).toBe(false);
41
157
  });
42
158
 
43
- it("returns false for a plain string", () => {
44
- expect(isSecretRef("plain")).toBe(false);
159
+ it("rejects names exceeding 63 characters", () => {
160
+ const longName = "A".repeat(64);
161
+ expect(secretNameSchema.safeParse(longName).success).toBe(false);
45
162
  });
46
163
 
47
- it("returns false for null", () => {
48
- expect(isSecretRef(null)).toBe(false);
164
+ it("accepts names at the 63-character limit", () => {
165
+ const maxName = "A".repeat(63);
166
+ expect(secretNameSchema.safeParse(maxName).success).toBe(true);
49
167
  });
50
168
  });
@@ -1,47 +1,82 @@
1
1
  import { z } from "zod";
2
2
 
3
3
  /**
4
- * Schema for a secret reference object.
5
- * Used in YAML descriptors to reference secrets stored in the Checkstack secret store.
4
+ * Valid characters for a secret name: letters, digits, underscores, and hyphens.
5
+ * This must stay in sync with the capture group in {@link SECRET_TEMPLATE_REGEX}.
6
6
  */
7
- export const secretRefSchema = z.object({
8
- secretRef: z.string().min(1, "Secret reference name must not be empty"),
9
- });
10
-
11
- export type SecretRef = z.infer<typeof secretRefSchema>;
7
+ export const SECRET_NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
12
8
 
13
9
  /**
14
- * Type guard to check if a value is a SecretRef object.
10
+ * Zod schema for validating secret names at creation time.
11
+ * Ensures the name can be referenced via `${{ secrets.NAME }}`.
15
12
  */
16
- export function isSecretRef(value: unknown): value is SecretRef {
17
- if (value === null || value === undefined || typeof value !== "object") {
18
- return false;
19
- }
20
- return (
21
- "secretRef" in value &&
22
- typeof (value as SecretRef).secretRef === "string"
13
+ export const secretNameSchema = z
14
+ .string()
15
+ .min(1)
16
+ .max(63)
17
+ .regex(
18
+ SECRET_NAME_REGEX,
19
+ "Secret names must start with a letter and contain only letters, digits, underscores, or hyphens",
23
20
  );
24
- }
25
21
 
26
22
  /**
27
- * Creates a Zod schema for fields that accept either a plain string or a secret reference.
23
+ * Regex matching ${{ secrets.NAME }} template expressions in strings.
24
+ * Captures the secret name in group 1.
25
+ * Supports multiple occurrences within a single string value.
28
26
  *
29
- * In YAML descriptors, users can write either:
30
- * ```yaml
31
- * password: "dev-password" # plain string
32
- * password:
33
- * secretRef: production-db-creds # reference to secret store
34
- * ```
35
- *
36
- * The GitOps reconciliation engine resolves all secretRef values before calling
37
- * plugin reconcilers, so plugins always receive plain strings.
27
+ * @example
28
+ * "${{ secrets.DB_PASS }}" → captures "DB_PASS"
29
+ * "postgres://u:${{ secrets.PASS }}@${{ secrets.HOST }}/db" → captures "PASS", "HOST"
38
30
  */
39
- export function secretField() {
40
- return z.union([z.string(), secretRefSchema]);
41
- }
31
+ export const SECRET_TEMPLATE_REGEX =
32
+ /\$\{\{\s*secrets\.([a-zA-Z0-9_-]+)\s*\}\}/g;
42
33
 
43
34
  /**
44
- * The resolved type of a secret field is always a string.
45
- * After the reconciliation engine resolves secretRefs, all values are plain strings.
35
+ * Zod schema for validating secret template strings.
36
+ * Matches strings containing at least one ${{ secrets.NAME }} pattern.
46
37
  */
47
- export type ResolvedSecretField = string;
38
+ export const secretTemplateSchema = z.string().regex(
39
+ /\$\{\{\s*secrets\.[a-zA-Z0-9_-]+\s*\}\}/,
40
+ "Must contain a ${{ secrets.NAME }} reference",
41
+ );
42
+
43
+ /**
44
+ * Recursively walks a value and collects all unique secret names
45
+ * referenced via ${{ secrets.NAME }} patterns in string values.
46
+ *
47
+ * @returns Deduplicated array of secret names found in the value tree
48
+ */
49
+ export function collectSecretNames(params: { value: unknown }): string[] {
50
+ const names = new Set<string>();
51
+ collectFromValue(params.value, names);
52
+ return [...names];
53
+ }
54
+
55
+ function collectFromValue(value: unknown, names: Set<string>): void {
56
+ if (value === null || value === undefined) {
57
+ return;
58
+ }
59
+
60
+ if (typeof value === "string") {
61
+ // Reset regex lastIndex for safe reuse (global flag)
62
+ SECRET_TEMPLATE_REGEX.lastIndex = 0;
63
+ let match: RegExpExecArray | null;
64
+ while ((match = SECRET_TEMPLATE_REGEX.exec(value)) !== null) {
65
+ names.add(match[1]);
66
+ }
67
+ return;
68
+ }
69
+
70
+ if (Array.isArray(value)) {
71
+ for (const item of value) {
72
+ collectFromValue(item, names);
73
+ }
74
+ return;
75
+ }
76
+
77
+ if (typeof value === "object") {
78
+ for (const val of Object.values(value)) {
79
+ collectFromValue(val, names);
80
+ }
81
+ }
82
+ }