@oscharko-dev/keiko-evidence 0.2.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 (92) hide show
  1. package/dist/.tsbuildinfo +1 -0
  2. package/dist/aggregate.d.ts +4 -0
  3. package/dist/aggregate.d.ts.map +1 -0
  4. package/dist/aggregate.js +21 -0
  5. package/dist/build.d.ts +3 -0
  6. package/dist/build.d.ts.map +1 -0
  7. package/dist/build.js +227 -0
  8. package/dist/connected-context-evidence.d.ts +47 -0
  9. package/dist/connected-context-evidence.d.ts.map +1 -0
  10. package/dist/connected-context-evidence.js +197 -0
  11. package/dist/errors.d.ts +3 -0
  12. package/dist/errors.d.ts.map +1 -0
  13. package/dist/errors.js +4 -0
  14. package/dist/index-api.d.ts +15 -0
  15. package/dist/index-api.d.ts.map +1 -0
  16. package/dist/index-api.js +136 -0
  17. package/dist/index.d.ts +20 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +34 -0
  20. package/dist/persist.d.ts +9 -0
  21. package/dist/persist.d.ts.map +1 -0
  22. package/dist/persist.js +40 -0
  23. package/dist/promptEnhancement/index.d.ts +7 -0
  24. package/dist/promptEnhancement/index.d.ts.map +1 -0
  25. package/dist/promptEnhancement/index.js +10 -0
  26. package/dist/promptEnhancement/manifestSchema.d.ts +71 -0
  27. package/dist/promptEnhancement/manifestSchema.d.ts.map +1 -0
  28. package/dist/promptEnhancement/manifestSchema.js +307 -0
  29. package/dist/promptEnhancement/redaction.d.ts +17 -0
  30. package/dist/promptEnhancement/redaction.d.ts.map +1 -0
  31. package/dist/promptEnhancement/redaction.js +66 -0
  32. package/dist/promptEnhancement/store.d.ts +64 -0
  33. package/dist/promptEnhancement/store.d.ts.map +1 -0
  34. package/dist/promptEnhancement/store.js +409 -0
  35. package/dist/qualityIntelligence/candidatesArtifact.d.ts +74 -0
  36. package/dist/qualityIntelligence/candidatesArtifact.d.ts.map +1 -0
  37. package/dist/qualityIntelligence/candidatesArtifact.js +258 -0
  38. package/dist/qualityIntelligence/companionStore.d.ts +37 -0
  39. package/dist/qualityIntelligence/companionStore.d.ts.map +1 -0
  40. package/dist/qualityIntelligence/companionStore.js +158 -0
  41. package/dist/qualityIntelligence/figmaSnapshot/schema.d.ts +123 -0
  42. package/dist/qualityIntelligence/figmaSnapshot/schema.d.ts.map +1 -0
  43. package/dist/qualityIntelligence/figmaSnapshot/schema.js +163 -0
  44. package/dist/qualityIntelligence/figmaSnapshot/store.d.ts +144 -0
  45. package/dist/qualityIntelligence/figmaSnapshot/store.d.ts.map +1 -0
  46. package/dist/qualityIntelligence/figmaSnapshot/store.js +898 -0
  47. package/dist/qualityIntelligence/index.d.ts +18 -0
  48. package/dist/qualityIntelligence/index.d.ts.map +1 -0
  49. package/dist/qualityIntelligence/index.js +21 -0
  50. package/dist/qualityIntelligence/manifestSchema.d.ts +154 -0
  51. package/dist/qualityIntelligence/manifestSchema.d.ts.map +1 -0
  52. package/dist/qualityIntelligence/manifestSchema.js +70 -0
  53. package/dist/qualityIntelligence/redaction.d.ts +10 -0
  54. package/dist/qualityIntelligence/redaction.d.ts.map +1 -0
  55. package/dist/qualityIntelligence/redaction.js +103 -0
  56. package/dist/qualityIntelligence/retention.d.ts +71 -0
  57. package/dist/qualityIntelligence/retention.d.ts.map +1 -0
  58. package/dist/qualityIntelligence/retention.js +287 -0
  59. package/dist/qualityIntelligence/retentionPolicy.d.ts +10 -0
  60. package/dist/qualityIntelligence/retentionPolicy.d.ts.map +1 -0
  61. package/dist/qualityIntelligence/retentionPolicy.js +38 -0
  62. package/dist/qualityIntelligence/store.d.ts +95 -0
  63. package/dist/qualityIntelligence/store.d.ts.map +1 -0
  64. package/dist/qualityIntelligence/store.js +483 -0
  65. package/dist/redaction.d.ts +2 -0
  66. package/dist/redaction.d.ts.map +1 -0
  67. package/dist/redaction.js +4 -0
  68. package/dist/report.d.ts +17 -0
  69. package/dist/report.d.ts.map +1 -0
  70. package/dist/report.js +50 -0
  71. package/dist/retention.d.ts +4 -0
  72. package/dist/retention.d.ts.map +1 -0
  73. package/dist/retention.js +95 -0
  74. package/dist/runid.d.ts +2 -0
  75. package/dist/runid.d.ts.map +1 -0
  76. package/dist/runid.js +4 -0
  77. package/dist/side-file.d.ts +9 -0
  78. package/dist/side-file.d.ts.map +1 -0
  79. package/dist/side-file.js +102 -0
  80. package/dist/store.d.ts +8 -0
  81. package/dist/store.d.ts.map +1 -0
  82. package/dist/store.js +332 -0
  83. package/dist/types.d.ts +3 -0
  84. package/dist/types.d.ts.map +1 -0
  85. package/dist/types.js +5 -0
  86. package/dist/version.d.ts +2 -0
  87. package/dist/version.d.ts.map +1 -0
  88. package/dist/version.js +1 -0
  89. package/dist/workflow-evidence.d.ts +36 -0
  90. package/dist/workflow-evidence.d.ts.map +1 -0
  91. package/dist/workflow-evidence.js +158 -0
  92. package/package.json +32 -0
@@ -0,0 +1,258 @@
1
+ // Quality Intelligence generated-candidate artifact (Issue #274/#280, Epic #270, ADR-0023 D8).
2
+ //
3
+ // The run manifest (`<runId>.qi.json`) carries only counts + refs. The reviewable, exportable
4
+ // product — the generated test-case bodies — is persisted here as a companion artifact
5
+ // `<runId>.candidates.json`. Bodies are redacted (every string leaf) BEFORE persist so a candidate
6
+ // that echoed a secret-shaped source string cannot reach disk, preview, or export unredacted
7
+ // (Issue #284). Stored as plain rows (branded IDs collapse to strings on the wire).
8
+ import { QualityIntelligence } from "@oscharko-dev/keiko-contracts";
9
+ import { createNodeContainedJsonArtifactStore, } from "./companionStore.js";
10
+ import { EvidenceReadError } from "../errors.js";
11
+ export const QUALITY_INTELLIGENCE_CANDIDATES_SCHEMA_VERSION = 1;
12
+ // Exported (but intentionally NOT re-exported from the package barrel) so the run-deletion path
13
+ // can reference the evidence-owned companion suffix without duplicating the literal.
14
+ export const CANDIDATES_SUFFIX = ".candidates.json";
15
+ const cloneQualityVerdict = (verdict) => ({
16
+ verdict: verdict.verdict,
17
+ score: verdict.score,
18
+ dimensions: verdict.dimensions.map((dimension) => ({ ...dimension })),
19
+ overallRationale: verdict.overallRationale,
20
+ });
21
+ const toRow = (candidate) => {
22
+ const qualityVerdict = candidate.qualityVerdict;
23
+ return {
24
+ id: String(candidate.id),
25
+ title: candidate.title,
26
+ preconditions: [...candidate.preconditions],
27
+ steps: [...candidate.steps],
28
+ expectedResults: [...candidate.expectedResults],
29
+ priority: candidate.priority,
30
+ riskClass: candidate.riskClass,
31
+ tags: [...candidate.tags],
32
+ status: candidate.status,
33
+ derivedFromAtomIds: candidate.derivedFromAtomIds.map(String),
34
+ ...(qualityVerdict !== undefined
35
+ ? { qualityVerdict: cloneQualityVerdict(qualityVerdict) }
36
+ : {}),
37
+ };
38
+ };
39
+ const PRIORITIES = new Set(QualityIntelligence.QUALITY_INTELLIGENCE_PRIORITIES);
40
+ const RISK_CLASSES = new Set(QualityIntelligence.QUALITY_INTELLIGENCE_RISK_CLASSES);
41
+ const TEST_CASE_STATUSES = new Set(QualityIntelligence.QUALITY_INTELLIGENCE_TEST_CASE_STATUSES);
42
+ const TEST_QUALITY_DIMENSIONS = new Set(QualityIntelligence.TEST_QUALITY_RUBRIC_DIMENSIONS);
43
+ const isObjectRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
44
+ const isNonEmptyString = (value) => typeof value === "string" && value.length > 0;
45
+ const isStringArray = (value) => Array.isArray(value) && value.every((item) => typeof item === "string");
46
+ const isString = (value) => typeof value === "string";
47
+ const isPriority = (value) => typeof value === "string" && PRIORITIES.has(value);
48
+ const isRiskClass = (value) => typeof value === "string" && RISK_CLASSES.has(value);
49
+ const isTestCaseStatus = (value) => typeof value === "string" && TEST_CASE_STATUSES.has(value);
50
+ const isQualityVerdictValue = (value) => value === "strong" || value === "weak";
51
+ const isScore = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 100;
52
+ const isDimensionScore = (value) => isScore(value) && Number.isInteger(value);
53
+ const isRubricDimensionName = (value) => typeof value === "string" && TEST_QUALITY_DIMENSIONS.has(value);
54
+ function isRubricDimension(value) {
55
+ return (isObjectRecord(value) &&
56
+ isRubricDimensionName(value.name) &&
57
+ isDimensionScore(value.score) &&
58
+ isString(value.rationale));
59
+ }
60
+ function isCandidateQualityVerdict(value) {
61
+ return (isObjectRecord(value) &&
62
+ isQualityVerdictValue(value.verdict) &&
63
+ isScore(value.score) &&
64
+ Array.isArray(value.dimensions) &&
65
+ value.dimensions.every(isRubricDimension) &&
66
+ isString(value.overallRationale));
67
+ }
68
+ const isEditedBy = (value) => value === "human" || value === "api";
69
+ // An edited list field persists as an array of non-blank strings — the edit route rejects blank
70
+ // ("") items before persist (editRoutes.ts isListField). Empty arrays are accepted on read so a
71
+ // legitimately-cleared preconditions/tags list still loads: strict on write, fail-open on read. The
72
+ // route additionally enforces minItems:1 for steps/expectedResults, but that is a write-time domain
73
+ // rule (a body must have at least one step); on read we stay deliberately fail-open for every list
74
+ // field here — this validator only gates the provenance log shape, never the candidate row itself
75
+ // (rows carry the merged effective value, validated separately by isStringArray).
76
+ const isListOfNonBlankStrings = (value) => Array.isArray(value) && value.every((item) => typeof item === "string" && item.length > 0);
77
+ const EDITABLE_FIELD_VALIDATORS = {
78
+ title: isNonEmptyString,
79
+ preconditions: isListOfNonBlankStrings,
80
+ steps: isListOfNonBlankStrings,
81
+ expectedResults: isListOfNonBlankStrings,
82
+ priority: isPriority,
83
+ riskClass: isRiskClass,
84
+ tags: isListOfNonBlankStrings,
85
+ };
86
+ function isEditableFields(value) {
87
+ if (!isObjectRecord(value))
88
+ return false;
89
+ const keys = Object.keys(value);
90
+ return keys.every((key) => EDITABLE_FIELD_VALIDATORS[key]?.(value[key]) ?? false);
91
+ }
92
+ const CANDIDATE_ROW_VALIDATORS = [
93
+ (row) => isNonEmptyString(row.id),
94
+ (row) => isNonEmptyString(row.title),
95
+ (row) => isStringArray(row.preconditions),
96
+ (row) => isStringArray(row.steps),
97
+ (row) => isStringArray(row.expectedResults),
98
+ (row) => isPriority(row.priority),
99
+ (row) => isRiskClass(row.riskClass),
100
+ (row) => isStringArray(row.tags),
101
+ (row) => isTestCaseStatus(row.status),
102
+ (row) => isStringArray(row.derivedFromAtomIds),
103
+ (row) => row.qualityVerdict === undefined || isCandidateQualityVerdict(row.qualityVerdict),
104
+ ];
105
+ function isCandidateRow(value) {
106
+ return isObjectRecord(value) && CANDIDATE_ROW_VALIDATORS.every((validate) => validate(value));
107
+ }
108
+ function isEditedRevision(value) {
109
+ if (!isObjectRecord(value))
110
+ return false;
111
+ const provenance = value.provenance;
112
+ return (isNonEmptyString(value.candidateId) &&
113
+ isObjectRecord(provenance) &&
114
+ isNonEmptyString(provenance.editedAt) &&
115
+ isEditedBy(provenance.editedBy) &&
116
+ isNonEmptyString(provenance.editorLabel) &&
117
+ isEditableFields(value.editedFields));
118
+ }
119
+ function invalidArtifact(message) {
120
+ throw new EvidenceReadError(message);
121
+ }
122
+ function readArtifactStringField(record, key, message) {
123
+ const value = record[key];
124
+ if (!isNonEmptyString(value))
125
+ invalidArtifact(message);
126
+ return value;
127
+ }
128
+ function readArtifactCandidates(record) {
129
+ const { candidates } = record;
130
+ if (!Array.isArray(candidates) || !candidates.every(isCandidateRow)) {
131
+ invalidArtifact("QI candidates companion schema invalid: candidates[] has an invalid row");
132
+ }
133
+ return candidates;
134
+ }
135
+ function readArtifactEditedRevisions(record) {
136
+ const { editedRevisions } = record;
137
+ if (editedRevisions === undefined)
138
+ return undefined;
139
+ if (!Array.isArray(editedRevisions) || !editedRevisions.every(isEditedRevision)) {
140
+ invalidArtifact("QI candidates companion schema invalid: editedRevisions[] has an invalid revision");
141
+ }
142
+ return editedRevisions;
143
+ }
144
+ // Strict-schema gate on read: reject any artifact whose version literal drifts so a stale or
145
+ // tampered file fails closed instead of surfacing a wrong shape to the BFF.
146
+ const parseArtifact = (value) => {
147
+ if (!isObjectRecord(value)) {
148
+ invalidArtifact("QI candidates companion schema invalid: expected an object");
149
+ }
150
+ const record = value;
151
+ if (record.qiCandidatesSchemaVersion !== QUALITY_INTELLIGENCE_CANDIDATES_SCHEMA_VERSION) {
152
+ invalidArtifact("QI candidates companion schema invalid: unsupported schema version");
153
+ }
154
+ const runId = readArtifactStringField(record, "runId", "QI candidates companion schema invalid: runId must be a string");
155
+ const generatedAt = readArtifactStringField(record, "generatedAt", "QI candidates companion schema invalid: generatedAt must be a string");
156
+ const candidates = readArtifactCandidates(record);
157
+ const editedRevisions = readArtifactEditedRevisions(record);
158
+ return {
159
+ qiCandidatesSchemaVersion: QUALITY_INTELLIGENCE_CANDIDATES_SCHEMA_VERSION,
160
+ runId,
161
+ generatedAt,
162
+ candidates,
163
+ ...(editedRevisions !== undefined ? { editedRevisions } : {}),
164
+ };
165
+ };
166
+ const storeFor = (evidenceDir) => createNodeContainedJsonArtifactStore(evidenceDir, CANDIDATES_SUFFIX, { parse: parseArtifact });
167
+ /**
168
+ * Persist the generated candidate bodies for a run. Redacts every string leaf first, then writes
169
+ * the companion artifact atomically. Returns the on-disk location.
170
+ */
171
+ export const recordQualityIntelligenceCandidates = (input) => {
172
+ const rows = input.candidates.map(toRow);
173
+ const redactedRows = input.redact(rows);
174
+ const redactedEditedRevisions = input.editedRevisions === undefined
175
+ ? undefined
176
+ : input.redact(input.editedRevisions);
177
+ const artifact = {
178
+ qiCandidatesSchemaVersion: QUALITY_INTELLIGENCE_CANDIDATES_SCHEMA_VERSION,
179
+ runId: input.runId,
180
+ generatedAt: input.generatedAt,
181
+ candidates: redactedRows,
182
+ ...(redactedEditedRevisions !== undefined ? { editedRevisions: redactedEditedRevisions } : {}),
183
+ };
184
+ return storeFor(input.evidenceDir).record(input.runId, artifact);
185
+ };
186
+ export const loadQualityIntelligenceCandidates = (runId, options) => storeFor(options.evidenceDir).load(runId);
187
+ export const deleteQualityIntelligenceCandidates = (runId, options) => storeFor(options.evidenceDir).delete(runId);
188
+ const EDITABLE_KEYS = [
189
+ "title",
190
+ "preconditions",
191
+ "steps",
192
+ "expectedResults",
193
+ "priority",
194
+ "riskClass",
195
+ "tags",
196
+ ];
197
+ const hasEditedField = (fields) => EDITABLE_KEYS.some((key) => fields[key] !== undefined);
198
+ const mergeRow = (row, fields) => ({
199
+ ...row,
200
+ ...(fields.title !== undefined ? { title: fields.title } : {}),
201
+ ...(fields.preconditions !== undefined ? { preconditions: [...fields.preconditions] } : {}),
202
+ ...(fields.steps !== undefined ? { steps: [...fields.steps] } : {}),
203
+ ...(fields.expectedResults !== undefined ? { expectedResults: [...fields.expectedResults] } : {}),
204
+ ...(fields.priority !== undefined ? { priority: fields.priority } : {}),
205
+ ...(fields.riskClass !== undefined ? { riskClass: fields.riskClass } : {}),
206
+ ...(fields.tags !== undefined ? { tags: [...fields.tags] } : {}),
207
+ });
208
+ const sameStringArray = (left, right) => left.length === right.length && left.every((item, index) => item === right[index]);
209
+ const sameRow = (left, right) => left.id === right.id &&
210
+ left.title === right.title &&
211
+ sameStringArray(left.preconditions, right.preconditions) &&
212
+ sameStringArray(left.steps, right.steps) &&
213
+ sameStringArray(left.expectedResults, right.expectedResults) &&
214
+ left.priority === right.priority &&
215
+ left.riskClass === right.riskClass &&
216
+ sameStringArray(left.tags, right.tags) &&
217
+ left.status === right.status &&
218
+ sameStringArray(left.derivedFromAtomIds, right.derivedFromAtomIds);
219
+ export const applyQualityIntelligenceCandidateEdit = (input) => {
220
+ if (!hasEditedField(input.editedFields))
221
+ return { ok: false, reason: "no-edited-fields" };
222
+ const store = storeFor(input.evidenceDir);
223
+ const artifact = store.load(input.runId);
224
+ if (artifact === undefined)
225
+ return { ok: false, reason: "artifact-not-found" };
226
+ const existing = artifact.candidates.find((row) => row.id === input.candidateId);
227
+ if (existing === undefined)
228
+ return { ok: false, reason: "candidate-not-found" };
229
+ const redactedFields = input.redact(input.editedFields);
230
+ const updatedRow = mergeRow(existing, redactedFields);
231
+ if (sameRow(existing, updatedRow)) {
232
+ return { ok: true, candidate: existing, changed: false };
233
+ }
234
+ const candidates = artifact.candidates.map((row) => row.id === input.candidateId ? updatedRow : row);
235
+ // Redact the provenance label before persist: `editorLabel` is the one user-controlled free-text
236
+ // field of the provenance (the edit route derives it from the request body) and is stored as a
237
+ // string leaf of the candidates artifact — never write an unredacted label to disk. This restores
238
+ // parity with recordQualityIntelligenceCandidates (which redacts the whole editedRevisions[]) and
239
+ // upholds the file-level "every string leaf redacted before persist" invariant. Only the label is
240
+ // routed through the redactor: `editedAt` (machine ISO timestamp) and `editedBy` (closed enum) are
241
+ // server-set, carry no secret, and must stay byte-exact so the strict read validator still accepts
242
+ // them. `candidateId` likewise stays the matched row id so the revision keeps referencing its row.
243
+ const redactedProvenance = {
244
+ ...input.provenance,
245
+ editorLabel: input.redact(input.provenance.editorLabel),
246
+ };
247
+ const revision = {
248
+ candidateId: input.candidateId,
249
+ provenance: redactedProvenance,
250
+ editedFields: redactedFields,
251
+ };
252
+ store.record(input.runId, {
253
+ ...artifact,
254
+ candidates,
255
+ editedRevisions: [...(artifact.editedRevisions ?? []), revision],
256
+ });
257
+ return { ok: true, candidate: updatedRow, changed: true };
258
+ };
@@ -0,0 +1,37 @@
1
+ import { type WorkspaceFs } from "@oscharko-dev/keiko-workspace";
2
+ export interface ContainedJsonArtifactStore<T> {
3
+ readonly record: (runId: string, value: T) => string;
4
+ readonly load: (runId: string) => T | undefined;
5
+ readonly delete: (runId: string) => boolean;
6
+ readonly location: (runId: string) => string;
7
+ }
8
+ export interface ContainedJsonArtifactStoreOptions<T> {
9
+ readonly fs?: WorkspaceFs;
10
+ readonly randomSuffix?: () => string;
11
+ /** Validates + narrows a parsed JSON value; return `undefined` to reject a corrupt artifact. */
12
+ readonly parse: (value: unknown) => T | undefined;
13
+ }
14
+ /**
15
+ * Build a node-backed contained JSON artifact store for one `suffix` (e.g. `.candidates.json`).
16
+ * `record` overwrites in place (companions are mutable, unlike the write-once manifest): it writes
17
+ * a fresh atomic temp and renames over any existing file.
18
+ */
19
+ export declare function createNodeContainedJsonArtifactStore<T>(evidenceDir: string, suffix: string, options: ContainedJsonArtifactStoreOptions<T>): ContainedJsonArtifactStore<T>;
20
+ /**
21
+ * Idempotently delete ONE QI companion artifact `<runId><suffix>` from the contained `qi/` dir.
22
+ *
23
+ * Used by the run-deletion path (`deleteQualityIntelligenceRun`) to clean up companion artifacts
24
+ * that live alongside the run manifest. EXACT-suffix matching is mandatory: a non-leading `.` is a
25
+ * legal runId character (`assertValidRunId`), so `run-1` and `run-1.2` can coexist and a prefix
26
+ * (`startsWith`) sweep would let deleting `run-1` destroy `run-1.2`'s companion. By deriving the
27
+ * full `${runId}${suffix}` name from the validated run id, the delete is collision-free,
28
+ * realpath-contained at the base, and symlink-refusing (`deleteArtifactFile` lstat-gates `isFile`,
29
+ * which is false for a symlink). Returns true iff a regular single-link file was removed.
30
+ *
31
+ * Intentionally NOT re-exported from the package barrel — it is an internal seam consumed only by
32
+ * the deletion path, so the published surface stays unchanged.
33
+ */
34
+ export declare function deleteQualityIntelligenceCompanionArtifact(evidenceDir: string, runId: string, suffix: string, options?: {
35
+ readonly fs?: WorkspaceFs;
36
+ }): boolean;
37
+ //# sourceMappingURL=companionStore.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"companionStore.d.ts","sourceRoot":"","sources":["../../src/qualityIntelligence/companionStore.ts"],"names":[],"mappings":"AAuBA,OAAO,EAA0B,KAAK,WAAW,EAAE,MAAM,+BAA+B,CAAC;AAQzF,MAAM,WAAW,0BAA0B,CAAC,CAAC;IAC3C,QAAQ,CAAC,MAAM,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,KAAK,MAAM,CAAC;IACrD,QAAQ,CAAC,IAAI,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,CAAC,GAAG,SAAS,CAAC;IAChD,QAAQ,CAAC,MAAM,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC;IAC5C,QAAQ,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;CAC9C;AAED,MAAM,WAAW,iCAAiC,CAAC,CAAC;IAClD,QAAQ,CAAC,EAAE,CAAC,EAAE,WAAW,CAAC;IAC1B,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,MAAM,CAAC;IACrC,gGAAgG;IAChG,QAAQ,CAAC,KAAK,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,CAAC,GAAG,SAAS,CAAC;CACnD;AA2GD;;;;GAIG;AACH,wBAAgB,oCAAoC,CAAC,CAAC,EACpD,WAAW,EAAE,MAAM,EACnB,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,iCAAiC,CAAC,CAAC,CAAC,GAC5C,0BAA0B,CAAC,CAAC,CAAC,CAwB/B;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,0CAA0C,CACxD,WAAW,EAAE,MAAM,EACnB,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,MAAM,EACd,OAAO,GAAE;IAAE,QAAQ,CAAC,EAAE,CAAC,EAAE,WAAW,CAAA;CAAO,GAC1C,OAAO,CAIT"}
@@ -0,0 +1,158 @@
1
+ // Quality Intelligence companion-artifact store (Issue #274/#280/#282, Epic #270, ADR-0023 D7+D8).
2
+ //
3
+ // Generic contained JSON artifact store that lives ALONGSIDE the immutable run manifest under
4
+ // `<evidenceDir>/qi/`, keyed by `<runId><suffix>`. The run manifest (`<runId>.qi.json`) stays the
5
+ // integrity-hashed, write-once evidence record; companion artifacts carry the MUTABLE product
6
+ // surfaces the manifest deliberately does not (generated candidate bodies for review/export, and
7
+ // the human review/lifecycle state). Suffix isolation keeps `listQualityIntelligenceRuns` (which
8
+ // only counts `.qi.json`) blind to companions.
9
+ //
10
+ // Same safety discipline as the manifest store: realpath-contained base, validated runId-derived
11
+ // filename, atomic O_EXCL temp + rename, 0o700 dir / 0o600 file intent.
12
+ import { randomUUID } from "node:crypto";
13
+ import { chmodSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
14
+ import { join, resolve } from "node:path";
15
+ import { resolveWithinWorkspace } from "@oscharko-dev/keiko-workspace";
16
+ import { nodeWorkspaceFs } from "@oscharko-dev/keiko-workspace/internal/fs";
17
+ import { assertValidRunId } from "@oscharko-dev/keiko-security";
18
+ import { EvidenceReadError, EvidenceWriteError } from "../errors.js";
19
+ import { QI_SUBDIR } from "./store.js";
20
+ const QI_DIR_MODE = 0o700;
21
+ function realBaseForWrite(baseDir, fs) {
22
+ try {
23
+ mkdirSync(baseDir, { recursive: true, mode: QI_DIR_MODE });
24
+ return fs.realPath(baseDir);
25
+ }
26
+ catch (error) {
27
+ throw new EvidenceWriteError(`cannot create QI companion directory: ${error instanceof Error ? error.message : "unknown"}`);
28
+ }
29
+ }
30
+ function realBaseForRead(baseDir, fs) {
31
+ if (!fs.exists(baseDir))
32
+ return undefined;
33
+ try {
34
+ return fs.realPath(baseDir);
35
+ }
36
+ catch (error) {
37
+ throw new EvidenceReadError(`cannot read QI companion directory: ${error instanceof Error ? error.message : "unknown"}`);
38
+ }
39
+ }
40
+ function lexicalArtifactPath(runId, suffix, realBase) {
41
+ assertValidRunId(runId);
42
+ const name = `${runId}${suffix}`;
43
+ return resolveWithinWorkspace(realBase, name);
44
+ }
45
+ function isSingleLinkRegularFile(path, fs) {
46
+ try {
47
+ const stat = fs.stat(path);
48
+ return stat.isFile && (stat.hardLinkCount ?? 1) <= 1;
49
+ }
50
+ catch (error) {
51
+ throw new EvidenceReadError(`cannot inspect QI companion: ${error instanceof Error ? error.message : "unknown"}`);
52
+ }
53
+ }
54
+ function assertWritableArtifactEntry(target, fs) {
55
+ const entry = lstatSync(target, { throwIfNoEntry: false });
56
+ if (entry === undefined)
57
+ return;
58
+ if (!entry.isFile() || !isSingleLinkRegularFile(target, fs)) {
59
+ throw new EvidenceWriteError("cannot overwrite a non-ledger QI companion artifact");
60
+ }
61
+ }
62
+ function atomicWrite(target, json, randomSuffix) {
63
+ const temp = `${target}.${randomSuffix()}.tmp`;
64
+ try {
65
+ writeFileSync(temp, json, { encoding: "utf8", flag: "wx" });
66
+ try {
67
+ chmodSync(temp, 0o600);
68
+ }
69
+ catch {
70
+ // non-fatal: not all filesystems support chmod (e.g. Windows)
71
+ }
72
+ renameSync(temp, target);
73
+ }
74
+ catch (error) {
75
+ rmSync(temp, { force: true });
76
+ throw new EvidenceWriteError(`QI companion write failed: ${error instanceof Error ? error.message : "unknown"}`);
77
+ }
78
+ }
79
+ function readArtifactFile(baseDir, fs, suffix, parse, runId) {
80
+ assertValidRunId(runId);
81
+ const realBase = realBaseForRead(baseDir, fs);
82
+ if (realBase === undefined)
83
+ return undefined;
84
+ const target = join(realBase, `${runId}${suffix}`);
85
+ if (lstatSync(target, { throwIfNoEntry: false })?.isFile() !== true)
86
+ return undefined;
87
+ if (!isSingleLinkRegularFile(target, fs))
88
+ return undefined;
89
+ let parsed;
90
+ try {
91
+ parsed = JSON.parse(readFileSync(target, "utf8"));
92
+ }
93
+ catch (error) {
94
+ throw new EvidenceReadError(`QI companion is not valid JSON: ${error instanceof Error ? error.message : "unknown"}`);
95
+ }
96
+ return parse(parsed);
97
+ }
98
+ function deleteArtifactFile(baseDir, fs, suffix, runId) {
99
+ assertValidRunId(runId);
100
+ const realBase = realBaseForRead(baseDir, fs);
101
+ if (realBase === undefined)
102
+ return false;
103
+ const target = lexicalArtifactPath(runId, suffix, realBase);
104
+ if (lstatSync(target, { throwIfNoEntry: false })?.isFile() !== true)
105
+ return false;
106
+ if (!isSingleLinkRegularFile(target, fs))
107
+ return false;
108
+ rmSync(target, { force: true });
109
+ return true;
110
+ }
111
+ /**
112
+ * Build a node-backed contained JSON artifact store for one `suffix` (e.g. `.candidates.json`).
113
+ * `record` overwrites in place (companions are mutable, unlike the write-once manifest): it writes
114
+ * a fresh atomic temp and renames over any existing file.
115
+ */
116
+ export function createNodeContainedJsonArtifactStore(evidenceDir, suffix, options) {
117
+ const baseDir = join(evidenceDir, QI_SUBDIR);
118
+ const fs = options.fs ?? nodeWorkspaceFs;
119
+ const randomSuffix = options.randomSuffix ?? randomUUID;
120
+ return {
121
+ record: (runId, value) => {
122
+ assertValidRunId(runId);
123
+ const realBase = realBaseForWrite(baseDir, fs);
124
+ const target = lexicalArtifactPath(runId, suffix, realBase);
125
+ assertWritableArtifactEntry(target, fs);
126
+ atomicWrite(target, JSON.stringify(value), randomSuffix);
127
+ return target;
128
+ },
129
+ load: (runId) => readArtifactFile(baseDir, fs, suffix, options.parse, runId),
130
+ delete: (runId) => deleteArtifactFile(baseDir, fs, suffix, runId),
131
+ location: (runId) => {
132
+ assertValidRunId(runId);
133
+ const realBase = realBaseForRead(baseDir, fs);
134
+ return realBase === undefined
135
+ ? join(resolve(baseDir), `${runId}${suffix}`)
136
+ : lexicalArtifactPath(runId, suffix, realBase);
137
+ },
138
+ };
139
+ }
140
+ /**
141
+ * Idempotently delete ONE QI companion artifact `<runId><suffix>` from the contained `qi/` dir.
142
+ *
143
+ * Used by the run-deletion path (`deleteQualityIntelligenceRun`) to clean up companion artifacts
144
+ * that live alongside the run manifest. EXACT-suffix matching is mandatory: a non-leading `.` is a
145
+ * legal runId character (`assertValidRunId`), so `run-1` and `run-1.2` can coexist and a prefix
146
+ * (`startsWith`) sweep would let deleting `run-1` destroy `run-1.2`'s companion. By deriving the
147
+ * full `${runId}${suffix}` name from the validated run id, the delete is collision-free,
148
+ * realpath-contained at the base, and symlink-refusing (`deleteArtifactFile` lstat-gates `isFile`,
149
+ * which is false for a symlink). Returns true iff a regular single-link file was removed.
150
+ *
151
+ * Intentionally NOT re-exported from the package barrel — it is an internal seam consumed only by
152
+ * the deletion path, so the published surface stays unchanged.
153
+ */
154
+ export function deleteQualityIntelligenceCompanionArtifact(evidenceDir, runId, suffix, options = {}) {
155
+ const baseDir = join(evidenceDir, QI_SUBDIR);
156
+ const fs = options.fs ?? nodeWorkspaceFs;
157
+ return deleteArtifactFile(baseDir, fs, suffix, runId);
158
+ }
@@ -0,0 +1,123 @@
1
+ export declare const FIGMA_SNAPSHOT_SCHEMA_VERSION: 1;
2
+ /** A reference to one rendered screen image written as a binary side-file. */
3
+ export interface FigmaSnapshotImageRef {
4
+ readonly mimeType: "image/png";
5
+ /** Path RELATIVE to the per-run side-file subdir. */
6
+ readonly relativePath: string;
7
+ readonly sha256: string;
8
+ readonly byteLength: number;
9
+ }
10
+ /** Why a screen was excluded from the snapshot. The `render-fetch-failed:<CODE>` variant carries
11
+ * the FigmaConnectorErrorCode suffix when the download threw a coded error, letting retention
12
+ * and metrics distinguish misconfigured egress from an unclassified network flake.
13
+ */
14
+ export type FigmaSnapshotSkipReason = "render-url-missing" | "render-url-blocked" | "render-screen-cap-exceeded" | "render-fetch-failed" | `render-fetch-failed:${string}` | "render-empty" | "render-oversized";
15
+ export interface FigmaSnapshotSkippedScreenRow {
16
+ readonly screenId: string;
17
+ readonly reason: FigmaSnapshotSkipReason;
18
+ }
19
+ export interface FigmaSnapshotScreenRow {
20
+ readonly screenId: string;
21
+ /** Opaque serialised Screen-IR (#752). Design content — kept, not redacted away. */
22
+ readonly irJson: unknown;
23
+ readonly image: FigmaSnapshotImageRef;
24
+ readonly integrityHash: string;
25
+ }
26
+ /** A screen whose structural Screen-IR is persisted but whose PNG render is absent. */
27
+ export interface FigmaSnapshotStructuralScreenRow {
28
+ readonly screenId: string;
29
+ /** Why the screen has no render side-file. Mirrors the matching skippedScreens row. */
30
+ readonly reason: FigmaSnapshotSkipReason;
31
+ /** Opaque serialised Screen-IR (#752). Design content — kept, not redacted away. */
32
+ readonly irJson: unknown;
33
+ readonly integrityHash: string;
34
+ }
35
+ /**
36
+ * A raw inter-screen transition carried for the navigation/flow graph (#811). OPTIONAL and additive:
37
+ * a record without `links` (e.g. an older snapshot) is still valid and the navigation derivation
38
+ * degrades to zero nav items. NOT part of any integrity hash — `links` is non-identity design
39
+ * metadata, so adding it does not change the drift hash (#735). Node ids + trigger are design content
40
+ * (already redaction-safe); no token, secret, or outbound URL ever reaches this shape.
41
+ */
42
+ export interface FigmaSnapshotLinkRow {
43
+ readonly sourceNodeId: string;
44
+ readonly trigger: string;
45
+ readonly targetNodeId: string;
46
+ }
47
+ /** Token-free provenance carried for audit. `fetchedAt` is audit-only and NOT in any hash. */
48
+ export interface FigmaSnapshotProvenanceRow {
49
+ readonly fileKey: string;
50
+ readonly nodeId: string;
51
+ readonly version: string | undefined;
52
+ readonly fetchedAt: string;
53
+ }
54
+ export interface FigmaSnapshotRedactionSummary {
55
+ readonly totalStringsScanned: number;
56
+ readonly stringsRedacted: number;
57
+ readonly patternsMatched: Readonly<Record<string, number>>;
58
+ }
59
+ /** Tamper-evidence for optional #752 artifacts that are hash-neutral for drift identity. */
60
+ export interface FigmaSnapshotArtifactHashes {
61
+ readonly links?: string;
62
+ readonly tokens?: string;
63
+ readonly metrics?: string;
64
+ }
65
+ export interface FigmaSnapshotAugmentationMetrics {
66
+ readonly deterministic: number;
67
+ readonly modelAugmented: number;
68
+ readonly modelAugmentedShare: number;
69
+ }
70
+ export interface FigmaSnapshotNavGraphMetrics {
71
+ readonly screens: number;
72
+ readonly transitions: number;
73
+ }
74
+ export interface FigmaSnapshotA11yMetrics {
75
+ readonly findings: number;
76
+ }
77
+ /** Numeric-only operational metrics from Issue #760. No ids, names, links, text, or token. */
78
+ export interface FigmaSnapshotMetrics {
79
+ readonly reductionRatio: number;
80
+ readonly screenCount: number;
81
+ readonly renderCount: number;
82
+ readonly designTokenCount: number;
83
+ readonly augmentation: FigmaSnapshotAugmentationMetrics;
84
+ readonly navGraph?: FigmaSnapshotNavGraphMetrics;
85
+ readonly a11y?: FigmaSnapshotA11yMetrics;
86
+ }
87
+ export interface FigmaSnapshotRecord {
88
+ readonly figmaSnapshotSchemaVersion: typeof FIGMA_SNAPSHOT_SCHEMA_VERSION;
89
+ readonly runId: string;
90
+ readonly provenance: FigmaSnapshotProvenanceRow;
91
+ readonly screens: readonly FigmaSnapshotScreenRow[];
92
+ readonly skippedScreens: readonly FigmaSnapshotSkippedScreenRow[];
93
+ /**
94
+ * Structural IR for screens without a PNG render. Optional for older snapshots. New snapshots
95
+ * write one row for each skipped screen whose IR was available, so downstream QI can still use
96
+ * the JSON even when rendering is capped or degraded.
97
+ */
98
+ readonly structuralScreens?: readonly FigmaSnapshotStructuralScreenRow[];
99
+ /** Raw inter-screen transitions for the navigation/flow graph (#811). Optional + additive. */
100
+ readonly links?: readonly FigmaSnapshotLinkRow[];
101
+ /**
102
+ * The deterministic design-tokens artifact (#752) — colours, typography, spacing, radius — kept as
103
+ * an opaque serialised value (like {@link FigmaSnapshotScreenRow.irJson}) so design-to-code (#755)
104
+ * can consume the tokens from the STORED snapshot without re-deriving them (the structural style
105
+ * fields they come from are pruned out of the lean per-screen IR). OPTIONAL + additive: a record
106
+ * without `tokens` (an older snapshot) is still valid and code-gen emits an empty token table. NOT
107
+ * part of any integrity hash — design tokens are non-identity design metadata, so adding them does
108
+ * not change the drift hash (#735). Design content (no token/secret/outbound URL reaches this shape).
109
+ */
110
+ readonly tokens?: unknown;
111
+ /** Durable numeric operational metrics (#760), safe to expose and reload with the snapshot. */
112
+ readonly metrics?: FigmaSnapshotMetrics;
113
+ /** Separate integrity hashes for optional hash-neutral artifacts (`links`/`tokens`) when present. */
114
+ readonly artifactHashes?: FigmaSnapshotArtifactHashes;
115
+ readonly integrityHash: string;
116
+ readonly redactionSummary: FigmaSnapshotRedactionSummary;
117
+ }
118
+ export interface FigmaSnapshotValidationResult {
119
+ readonly ok: boolean;
120
+ readonly reason: string | undefined;
121
+ }
122
+ export declare function validateFigmaSnapshotRecord(value: unknown): FigmaSnapshotValidationResult;
123
+ //# sourceMappingURL=schema.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../../src/qualityIntelligence/figmaSnapshot/schema.ts"],"names":[],"mappings":"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"}