pi-aia-asf 0.13.0 → 0.13.2
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 +61 -0
- package/lib/code-health/config.mjs +10 -0
- package/lib/code-health/model.mjs +126 -1
- package/lib/gates/config.mjs +10 -1
- package/lib/gates/defaults.mjs +37 -1
- package/lib/gates/measurement.mjs +12 -4
- package/package.json +1 -1
- package/skills/aia-asf/references/06b-testing-qa.md +6 -0
- package/skills/aia-asf/references/06j-data-model.md +32 -7
- package/skills/aia-asf/references/06k-performance.md +9 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,67 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.13.2] - 2026-10-16
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **The model gate is now store-agnostic in fact, not just in intent.** It knew
|
|
15
|
+
SQL DDL and ORM cascade spellings and nothing else, so in a document, key-value,
|
|
16
|
+
wide-column or search store — where there is no constraint to declare a cascade
|
|
17
|
+
in — the gate was blind to the exact defect it exists to catch. A third rule,
|
|
18
|
+
`handwritten-cascade`, catches the cascade that exists only as code someone
|
|
19
|
+
wrote: a multi-delete driven by a reference to *another* entity.
|
|
20
|
+
- Covered by name: **MongoDB/Mongoose** (`deleteMany({ userId })`, including
|
|
21
|
+
ES6 shorthand), **Firestore** (`collectionGroup` / recursive subcollection
|
|
22
|
+
delete, where the field name is an argument, not a key), **DynamoDB**
|
|
23
|
+
(`DeleteItem` / `BatchWriteItem` keyed by a parent), **CouchDB** (`_deleted:
|
|
24
|
+
true` in a `bulkDocs`), **Redis** (the key-pattern wipe — the prefix *is* the
|
|
25
|
+
parent), **Elasticsearch** (`deleteByQuery` on a parent field). ORM
|
|
26
|
+
declarations were already matched: `CascadeType.REMOVE`/`ALL`,
|
|
27
|
+
`orphanRemoval`, `DeleteBehavior.Cascade`, Prisma/Sequelize `onDelete:
|
|
28
|
+
Cascade`, Django `on_delete=models.CASCADE`, Rails `dependent: :destroy` /
|
|
29
|
+
`:delete_all`, SQLAlchemy `cascade = "…delete…"`.
|
|
30
|
+
- **Near-miss fixtures guard the false-positive side**, because a rule that
|
|
31
|
+
fires on ordinary deletes gets switched off and the coverage goes with it:
|
|
32
|
+
deleting a row by its own identity, `updateMany` by reference, a Redis `del`
|
|
33
|
+
of one known key, a Firestore query on a plain field, a CouchDB bulk insert,
|
|
34
|
+
an ES delete-by-query on a non-reference, a DynamoDB point read — all clean.
|
|
35
|
+
- A fire asks for a **declaration**, it does not claim the ownership is wrong;
|
|
36
|
+
`model.handWrittenCascade: false` switches it off and
|
|
37
|
+
`model.extraHandWrittenPatterns` adds a project's own dialect (an invalid
|
|
38
|
+
regex is dropped, never fatal).
|
|
39
|
+
- `test-model-gate.mjs` grew from 73 to **92 checks**, and the assertion that
|
|
40
|
+
recorded this as a *documented gap* ("a hand-rolled document-store cascade is
|
|
41
|
+
not caught — invariant tests carry it") is now inverted to assert it **is**
|
|
42
|
+
caught. The production shape that started this — `deleteMany({ question_id:
|
|
43
|
+
{ $in: ids } })` over a child collection — is the fixture.
|
|
44
|
+
|
|
45
|
+
## [0.13.1] - 2026-10-16
|
|
46
|
+
|
|
47
|
+
### Fixed
|
|
48
|
+
|
|
49
|
+
- **A measurement without a target no longer reads as a verdict.** 0.13.0
|
|
50
|
+
shipped the measurement contract but left the last clause of the requirement
|
|
51
|
+
unimplemented: *where no defensible default exists the output is
|
|
52
|
+
`no-default: needs a declared target`, never a silent pass*. Now:
|
|
53
|
+
- **`perf.target` is the one parameter that ships no default.** A latency or
|
|
54
|
+
throughput target is a product decision; a number ASF invented would be a
|
|
55
|
+
fabricated specification. With nobody having declared one, the parameter
|
|
56
|
+
resolves to `value: null, origin: "no-default"` and the verdict text is the
|
|
57
|
+
literal `no-default: needs a declared target`.
|
|
58
|
+
- **`target` is required on latency and throughput claims.** A claim can carry
|
|
59
|
+
a complete measurement contract — percentile, load, samples, environment,
|
|
60
|
+
lab-or-field — and still be judging nothing. That is now reported, with
|
|
61
|
+
`noDefault` set on the result. A **regression** claim still needs no target:
|
|
62
|
+
it compares two arms rather than a threshold.
|
|
63
|
+
- **The parameter guard keeps its teeth.** The flag that exempts a parameter
|
|
64
|
+
from a band is the same flag that *forbids* it from shipping a general
|
|
65
|
+
default, so a fabricated number cannot smuggle itself in as "no default
|
|
66
|
+
needed". Adding `perf.target` also tripped the existing guard for a missing
|
|
67
|
+
`meaning` field — the guard working as intended on its first real test.
|
|
68
|
+
- `SKILL.md`/06k/06b state the rule where the contract is stated, and the
|
|
69
|
+
config screen shows `no-default` as a first-class origin.
|
|
70
|
+
|
|
10
71
|
## [0.13.0] - 2026-10-16
|
|
11
72
|
|
|
12
73
|
### Added
|
|
@@ -48,9 +48,14 @@ export const DEFAULT_CONFIG = {
|
|
|
48
48
|
enabled: true,
|
|
49
49
|
requireDeclaration: true, // cascade requires an asf-model: declaration
|
|
50
50
|
deleteRebuild: true, // delete + reinsert of the same target
|
|
51
|
+
// A cascade written by hand in a store with no FK action to inspect —
|
|
52
|
+
// MongoDB, Firestore, DynamoDB, CouchDB, Redis, Elasticsearch. Without it
|
|
53
|
+
// the model gate reads as SQL-only, which is the blind spot 06j §5 names.
|
|
54
|
+
handWrittenCascade: true,
|
|
51
55
|
declarationWindowLines: 8,
|
|
52
56
|
rebuildWindowLines: 30,
|
|
53
57
|
extraCascadePatterns: [],
|
|
58
|
+
extraHandWrittenPatterns: [],
|
|
54
59
|
},
|
|
55
60
|
|
|
56
61
|
ignore: [
|
|
@@ -171,12 +176,17 @@ export function normalizeConfig(raw) {
|
|
|
171
176
|
enabled: bool(md.enabled, d.model.enabled),
|
|
172
177
|
requireDeclaration: bool(md.requireDeclaration, d.model.requireDeclaration),
|
|
173
178
|
deleteRebuild: bool(md.deleteRebuild, d.model.deleteRebuild),
|
|
179
|
+
handWrittenCascade: bool(md.handWrittenCascade, d.model.handWrittenCascade),
|
|
174
180
|
declarationWindowLines: num(md.declarationWindowLines, d.model.declarationWindowLines),
|
|
175
181
|
rebuildWindowLines: num(md.rebuildWindowLines, d.model.rebuildWindowLines),
|
|
176
182
|
extraCascadePatterns:
|
|
177
183
|
Array.isArray(md.extraCascadePatterns) && md.extraCascadePatterns.length > 0
|
|
178
184
|
? md.extraCascadePatterns.filter((x) => typeof x === "string" && x.length > 0)
|
|
179
185
|
: [],
|
|
186
|
+
extraHandWrittenPatterns:
|
|
187
|
+
Array.isArray(md.extraHandWrittenPatterns) && md.extraHandWrittenPatterns.length > 0
|
|
188
|
+
? md.extraHandWrittenPatterns.filter((x) => typeof x === "string" && x.length > 0)
|
|
189
|
+
: [],
|
|
180
190
|
},
|
|
181
191
|
ignore: Array.isArray(raw.ignore) && raw.ignore.every((x) => typeof x === "string") && raw.ignore.length > 0
|
|
182
192
|
? raw.ignore
|
|
@@ -39,6 +39,72 @@ const CASCADE_DECL_RES = [
|
|
|
39
39
|
{ marker: 'cascade = "…delete…"', re: /\bcascade\s*=\s*["'][^"']*delete/i },
|
|
40
40
|
];
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Hand-written cascades in stores that enforce nothing (P27, 06j §5).
|
|
44
|
+
*
|
|
45
|
+
* A relational FK action and an ORM mapping attribute at least announce
|
|
46
|
+
* themselves. In a document, key-value, wide-column or search store there is no
|
|
47
|
+
* constraint to declare, so the cascade exists only as code someone wrote —
|
|
48
|
+
* which is exactly where an instance silently inherits its parent's lifecycle.
|
|
49
|
+
* These patterns look for a MULTI-delete driven by a reference to another
|
|
50
|
+
* entity: the shape of "when the parent goes, these go too", implemented by
|
|
51
|
+
* hand and nowhere written down.
|
|
52
|
+
*
|
|
53
|
+
* Illustrative, not exhaustive: a project adds its own dialect with
|
|
54
|
+
* `extraHandWrittenPatterns`. A fire here asks for a declaration; it does not
|
|
55
|
+
* claim the ownership is wrong — that judgement stays in review.
|
|
56
|
+
*/
|
|
57
|
+
const HANDWRITTEN_CASCADE_RES = [
|
|
58
|
+
{ marker: "MongoDB/Mongoose deleteMany by reference", store: "document", re: /\.(?:deleteMany|remove|bulkDelete|destroyAll)\s*\(/ },
|
|
59
|
+
{
|
|
60
|
+
// Firestore spells a field name as an argument, not as a key:
|
|
61
|
+
// `where('postId', '==', id)`. The recursive subcollection delete is the
|
|
62
|
+
// classic way an instance inherits its parent's lifecycle here, and there
|
|
63
|
+
// is no constraint to enforce it — so the reference is a quoted field name.
|
|
64
|
+
marker: "Firestore collectionGroup / recursive delete",
|
|
65
|
+
store: "document",
|
|
66
|
+
re: /\.collectionGroup\s*\(|\bdeleteCollectionGroup\b|\bdeleteDoc\b|\.where\s*\(/i,
|
|
67
|
+
refRe: /["'`][\w.]*?(?:Id|_id)["'`]/,
|
|
68
|
+
},
|
|
69
|
+
{ marker: "DynamoDB batch/point delete", store: "wide-column", re: /\b(?:DeleteItem|BatchWriteItem|deleteItem|batchWrite)\b/ },
|
|
70
|
+
{
|
|
71
|
+
// CouchDB has no cascade either: a set of documents is selected and marked
|
|
72
|
+
// `_deleted` in one bulk write. The selection IS the ownership decision, so
|
|
73
|
+
// this dialect carries its own reference test (null = the marker suffices).
|
|
74
|
+
marker: "CouchDB bulk delete",
|
|
75
|
+
store: "document",
|
|
76
|
+
re: /_deleted\s*[:=]\s*true|\bbulkDocs\s*\([^)]*_deleted/i,
|
|
77
|
+
refRe: null,
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
// A key-pattern wipe is the key-value store's cascade: everything under the
|
|
81
|
+
// parent's prefix goes with it. The `*` is the reference — there is no key
|
|
82
|
+
// to look for, so this dialect carries its own.
|
|
83
|
+
marker: "Redis key-pattern wipe",
|
|
84
|
+
store: "key-value",
|
|
85
|
+
re: /\.(?:keys|scan)\s*\([^)]*\*|\b(?:del|unlink)\s*\(/i,
|
|
86
|
+
refRe: /["'`][^"'`]*\*|\b\w*(?:Id|_id)\b/,
|
|
87
|
+
},
|
|
88
|
+
{ marker: "Elasticsearch delete-by-query", store: "search", re: /(?:\.|_)delete_?by_?\s*query\s*\(|\bdeleteByQuery\b/i },
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A key that names ANOTHER entity rather than the row's own identity. This is
|
|
93
|
+
* what turns "a delete happened" into "a cascade was implemented by hand":
|
|
94
|
+
* `deleteOne({ id })` is a row removing itself, `deleteMany({ userId })` is one
|
|
95
|
+
* entity deciding the fate of another.
|
|
96
|
+
*/
|
|
97
|
+
const PARENT_REF_RE =
|
|
98
|
+
/(?:^|[{,(\s])(?!id\b|_id\b|pk\b|key\b|rowId\b|selfId\b)[A-Za-z][A-Za-z0-9]*(?:Id|_id)\s*[:=]/;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The same reference written as ES6 object shorthand — `deleteMany({ userId })`
|
|
102
|
+
* rather than `deleteMany({ userId: userId })`. Extremely common, and anchored
|
|
103
|
+
* to a brace pair so a bare `orderId` sitting in an enclosing parameter list
|
|
104
|
+
* does not turn an unrelated delete into a finding.
|
|
105
|
+
*/
|
|
106
|
+
const PARENT_REF_SHORTHAND_RE = /\{[^{}]*\b(?!id\b|_id\b|pk\b|key\b|rowId\b|selfId\b)[A-Za-z][A-Za-z0-9]*(?:Id|_id)\b[^{}]*\}/;
|
|
107
|
+
|
|
42
108
|
/** Writes and deletions, matched on verb shape rather than store dialect. */
|
|
43
109
|
const DELETE_VERBS = /^(deleteMany|deleteOne|deleteAll|destroyAll|bulkDelete|delete|remove|removeAll|destroy)$/i;
|
|
44
110
|
const INSERT_VERBS = /^(insertMany|insertOne|createMany|bulkCreate|bulkWrite|insert|insertAll|save|upsert|create)$/i;
|
|
@@ -96,6 +162,33 @@ export function extraCascadePatterns(cfg) {
|
|
|
96
162
|
return out;
|
|
97
163
|
}
|
|
98
164
|
|
|
165
|
+
/**
|
|
166
|
+
* Project-supplied dialect for the hand-written-cascade rule, same contract as
|
|
167
|
+
* extraCascadePatterns: a bad regex is skipped, never fatal. A project whose
|
|
168
|
+
* store is not in the illustrative set adds its own spelling rather than
|
|
169
|
+
* waiting for a release.
|
|
170
|
+
*/
|
|
171
|
+
export function extraPatterns(list, markerPrefix) {
|
|
172
|
+
const out = [];
|
|
173
|
+
for (const p of list || []) {
|
|
174
|
+
if (typeof p !== "string" || !p) continue;
|
|
175
|
+
try {
|
|
176
|
+
out.push({ marker: `${markerPrefix}: ${p}`, store: "project-declared", re: new RegExp(p, "i") });
|
|
177
|
+
} catch {
|
|
178
|
+
/* invalid regex — ignore rather than crash the gate */
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The collection/table a statement acts on, when it names one. */
|
|
185
|
+
export function targetOfStatement(stmt) {
|
|
186
|
+
const named = /\b(?:collection|from|table|model|entity|index)\s*\(\s*["']([\w.\-]+)["']/i.exec(stmt);
|
|
187
|
+
if (named) return named[1];
|
|
188
|
+
const receiver = /(?:^|[\s;}])([A-Za-z_$][\w$]*)\s*(?:\.\s*(?:deleteMany|deleteOne|delete|remove|bulkDelete|destroyAll|deleteByQuery|bulkDocs|keys|scan|del|unlink|deleteItem|batchWrite)\s*\()/i.exec(stmt);
|
|
189
|
+
return receiver ? receiver[1] : null;
|
|
190
|
+
}
|
|
191
|
+
|
|
99
192
|
/** Distance in lines to the nearest preceding asf-model tag, or null. */
|
|
100
193
|
export function nearestTag(lines, idx, windowLines) {
|
|
101
194
|
for (let d = 0; d <= windowLines; d++) {
|
|
@@ -214,6 +307,38 @@ export function scanModelSource(relPath, text, cfg = {}) {
|
|
|
214
307
|
}
|
|
215
308
|
}
|
|
216
309
|
|
|
310
|
+
// ── F3: a cascade implemented by hand in a store that enforces nothing ───
|
|
311
|
+
//
|
|
312
|
+
// The store-agnostic half of the gate. MongoDB, Firestore, DynamoDB, CouchDB,
|
|
313
|
+
// Redis and Elasticsearch have no FK action to inspect, so "the parent takes
|
|
314
|
+
// the children" exists only as a delete someone wrote. Catching it here is
|
|
315
|
+
// the difference between a model gate that reads as SQL-only and one that
|
|
316
|
+
// actually asks the question wherever the defect can happen.
|
|
317
|
+
if (cfg.handWrittenCascade !== false) {
|
|
318
|
+
const handWritten = [...HANDWRITTEN_CASCADE_RES, ...extraPatterns(cfg.extraHandWrittenPatterns, "custom hand-written cascade")];
|
|
319
|
+
for (let i = 0; i < lines.length; i++) {
|
|
320
|
+
const hit = handWritten.find((c) => c.re.test(lines[i]));
|
|
321
|
+
if (!hit) continue;
|
|
322
|
+
// The delete must be driven by a reference to another entity, else every
|
|
323
|
+
// ordinary `deleteOne({ id })` in a Mongo project would fire. `refRe: null`
|
|
324
|
+
// means the dialect's own marker already carries that meaning.
|
|
325
|
+
const stmt = enclosingStatement(lines, i);
|
|
326
|
+
const refRe = hit.refRe === undefined ? PARENT_REF_RE : hit.refRe;
|
|
327
|
+
if (refRe && !refRe.test(stmt) && !PARENT_REF_SHORTHAND_RE.test(stmt)) continue;
|
|
328
|
+
if (nearestTag(lines, i, tagWindow)) continue;
|
|
329
|
+
const child = childTableAt(lines, i) || targetOfStatement(stmt);
|
|
330
|
+
findings.push({
|
|
331
|
+
rule: "handwritten-cascade",
|
|
332
|
+
file: relPath,
|
|
333
|
+
line: i + 1,
|
|
334
|
+
table: child,
|
|
335
|
+
marker: hit.marker,
|
|
336
|
+
store: hit.store,
|
|
337
|
+
message: `${hit.marker}${child ? ` over ${child}` : ""} deletes by a reference to another entity — in a ${hit.store} store nothing enforces that, so this IS the cascade decision. Add \`asf-model: ${child || "<child>"} -> <parent> = composition|instance|ledger, owner: <who>\` within ${tagWindow} lines above, or \`asf-model-accept: <reason>\` (06j §2, §5).`,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
217
342
|
return findings.sort((a, b) => a.line - b.line || a.rule.localeCompare(b.rule));
|
|
218
343
|
}
|
|
219
344
|
|
|
@@ -268,4 +393,4 @@ export async function scanModel(root, { files, readFile, config } = {}) {
|
|
|
268
393
|
return { findings, scanned, errors };
|
|
269
394
|
}
|
|
270
395
|
|
|
271
|
-
export const MODEL_RULES = ["undeclared-cascade", "delete-then-reinsert"];
|
|
396
|
+
export const MODEL_RULES = ["undeclared-cascade", "delete-then-reinsert", "handwritten-cascade"];
|
package/lib/gates/config.mjs
CHANGED
|
@@ -79,9 +79,18 @@ export function assertParametersAreConsumed(projectConfig = {}, { knownGates } =
|
|
|
79
79
|
for (const [k, spec] of Object.entries(PARAMS)) {
|
|
80
80
|
if (!spec.gate) problems.push(`${k}: has no consuming gate`);
|
|
81
81
|
if (!spec.source || !String(spec.source).trim()) problems.push(`${k}: has no source — a default that cannot explain itself is a guess with better formatting`);
|
|
82
|
-
|
|
82
|
+
// A band exists to stop calibration widening a knob into an escape hatch.
|
|
83
|
+
// Two kinds of parameter have nothing to band: a non-adaptive constant (the
|
|
84
|
+
// general value is the only value), and a parameter with no general default
|
|
85
|
+
// at all — there is no number to drift. Anything else must carry one.
|
|
86
|
+
const unbanded = spec.band == null;
|
|
87
|
+
const exemptFromBand = !!spec.nonAdaptive || !!spec.requiresDeclaredTarget;
|
|
88
|
+
if (unbanded !== exemptFromBand) {
|
|
83
89
|
problems.push(`${k}: a band requires adaptability, and a non-adaptive constant must not have one`);
|
|
84
90
|
}
|
|
91
|
+
if (spec.requiresDeclaredTarget && spec.general !== null) {
|
|
92
|
+
problems.push(`${k}: declares requiresDeclaredTarget but ships a general default — that is the fabricated number this flag exists to prevent`);
|
|
93
|
+
}
|
|
85
94
|
}
|
|
86
95
|
return problems;
|
|
87
96
|
}
|
package/lib/gates/defaults.mjs
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* guess with better formatting (02-research.md, P33).
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
-
/** @typedef {'default'|'project'|'calibrated'|'override'} ValueOrigin */
|
|
28
|
+
/** @typedef {'default'|'project'|'calibrated'|'override'|'no-default'} ValueOrigin */
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
31
|
* @typedef {object} ParamSpec
|
|
@@ -48,6 +48,25 @@
|
|
|
48
48
|
* `.asf-test.json`.
|
|
49
49
|
*/
|
|
50
50
|
export const PARAMS = {
|
|
51
|
+
// ── The one parameter that must NOT have a default (P33) ───────────────
|
|
52
|
+
//
|
|
53
|
+
// Every other parameter here can explain its own number. This one cannot, and
|
|
54
|
+
// inventing one is the failure the spec names: ASF must not decide that a
|
|
55
|
+
// consumer's endpoint is supposed to answer in 2s. With nobody having
|
|
56
|
+
// declared a target the verdict is `no-default: needs a declared target` —
|
|
57
|
+
// never a silent pass, and never a number ASF made up.
|
|
58
|
+
"perf.target": {
|
|
59
|
+
key: "perf.target",
|
|
60
|
+
general: null,
|
|
61
|
+
type: "string",
|
|
62
|
+
band: null,
|
|
63
|
+
requiresDeclaredTarget: true,
|
|
64
|
+
meaning: "The declared performance target a scenario is judged against; with no target declared there is no verdict, only a measurement",
|
|
65
|
+
source:
|
|
66
|
+
"no defensible general default exists — a latency or throughput target is a product decision, and a number ASF invented would be a fabricated specification (06i). It must be declared by the spec it comes from.",
|
|
67
|
+
gate: "measurement",
|
|
68
|
+
},
|
|
69
|
+
|
|
51
70
|
// ── Performance placement (P34) ──────────────────────────────────────────
|
|
52
71
|
"perf.cheapBudgetSeconds": {
|
|
53
72
|
key: "perf.cheapBudgetSeconds",
|
|
@@ -291,6 +310,23 @@ export function resolveParam(key, tiers = {}) {
|
|
|
291
310
|
nonAdaptive: !!spec.nonAdaptive,
|
|
292
311
|
};
|
|
293
312
|
|
|
313
|
+
// A parameter with no defensible general default has exactly one legal
|
|
314
|
+
// output when nobody declared it: say so. Returning a value here would be the
|
|
315
|
+
// silent pass this branch exists to prevent, so an explicit project value or
|
|
316
|
+
// override is the only way it resolves.
|
|
317
|
+
if (spec.requiresDeclaredTarget) {
|
|
318
|
+
const declared =
|
|
319
|
+
Object.prototype.hasOwnProperty.call(overrides, key)
|
|
320
|
+
? overrides[key]
|
|
321
|
+
: Object.prototype.hasOwnProperty.call(projectConfig, key)
|
|
322
|
+
? projectConfig[key]
|
|
323
|
+
: null;
|
|
324
|
+
if (declared == null || declared === "") {
|
|
325
|
+
return { ...base, value: null, origin: "no-default", why: "no-default: needs a declared target" };
|
|
326
|
+
}
|
|
327
|
+
return { ...base, value: declared, origin: "project", why: "declared target" };
|
|
328
|
+
}
|
|
329
|
+
|
|
294
330
|
// Non-adaptive: the general value is the only value. A project or an override
|
|
295
331
|
// that tries to move it is reported, not obeyed — silently ignoring it would
|
|
296
332
|
// let a config file look like it had changed the rules.
|
|
@@ -22,12 +22,12 @@
|
|
|
22
22
|
|
|
23
23
|
export const CONTRACTS = {
|
|
24
24
|
latency: {
|
|
25
|
-
required: ["percentile", "load", "samples", "environment", "labOrField"],
|
|
26
|
-
hint: "a latency claim needs a percentile, the load it was measured at, the sample count, the environment,
|
|
25
|
+
required: ["percentile", "load", "samples", "environment", "labOrField", "target"],
|
|
26
|
+
hint: "a latency claim needs a percentile, the load it was measured at, the sample count, the environment, whether it is lab or field, and the target it is being judged against",
|
|
27
27
|
},
|
|
28
28
|
throughput: {
|
|
29
|
-
required: ["arrivalModel", "duration", "environment", "samples"],
|
|
30
|
-
hint: "a throughput claim needs an arrival model (closed-loop vs open-loop — coordinated omission changes the answer), the duration, the environment and the
|
|
29
|
+
required: ["arrivalModel", "duration", "environment", "samples", "target"],
|
|
30
|
+
hint: "a throughput claim needs an arrival model (closed-loop vs open-loop — coordinated omission changes the answer), the duration, the environment, the sample count and the target it is being judged against",
|
|
31
31
|
},
|
|
32
32
|
regression: {
|
|
33
33
|
required: ["comparison", "interleaved", "rawSamples", "cv", "samples", "environment"],
|
|
@@ -54,6 +54,14 @@ export function checkMeasurementContract(claim) {
|
|
|
54
54
|
});
|
|
55
55
|
const out = { ok: missing.length === 0, missing, claim };
|
|
56
56
|
if (!out.ok) out.hint = contract.hint;
|
|
57
|
+
// A claim can carry a full measurement contract and still have nothing to be
|
|
58
|
+
// compared against. "p95 was 180ms at 200 users, 30 samples, CI" is a real
|
|
59
|
+
// measurement; without a target it is not a verdict about meeting anything.
|
|
60
|
+
// ASF does not invent the target — that is a product decision — so the
|
|
61
|
+
// verdict here is the literal string the spec names, never a silent pass.
|
|
62
|
+
if (missing.includes("target")) {
|
|
63
|
+
out.noDefault = "no-default: needs a declared target";
|
|
64
|
+
}
|
|
57
65
|
return out;
|
|
58
66
|
}
|
|
59
67
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-aia-asf",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.2",
|
|
4
4
|
"description": "Ai Applied Agentic Software Factory — codifies the full software development flow: intake, research, spec capture, adversarial analysis, planning with approval gates, test-first implementation, and release. Requires pi-vigilant, pi-smart-web-search, pi-smart-fetch, and pi-aia-browser.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -484,6 +484,12 @@ do not accept it, and do not quietly drop it.**
|
|
|
484
484
|
| Throughput | arrival model (closed-loop or open-loop) · duration · environment · sample count |
|
|
485
485
|
| Regression | statistical comparison · interleaved randomized measurement · retained raw samples · observed CV · sample count · environment |
|
|
486
486
|
|
|
487
|
+
Latency and throughput claims additionally carry **the target they are judged
|
|
488
|
+
against**. ASF ships no default for it (`perf.target` is the one parameter with
|
|
489
|
+
no general default): with no declared target the output is
|
|
490
|
+
`no-default: needs a declared target`, never a silent pass and never a number
|
|
491
|
+
ASF invented — deciding a consumer's SLA is a product decision, not a default.
|
|
492
|
+
|
|
487
493
|
Full text, rationale and the arrival-model trap:
|
|
488
494
|
[06k-performance.md](06k-performance.md) §2. The DoD line *"performance targets
|
|
489
495
|
from the spec are met"* is unfalsifiable without this — which is why it has
|
|
@@ -182,13 +182,38 @@ requirement: "is this ownership correct?" is judgment and stays in review, while
|
|
|
182
182
|
defect is not SQL-shaped: an instance inheriting its parent's lifecycle happens
|
|
183
183
|
in a relational FK action, in an ORM mapping attribute, in a document store's
|
|
184
184
|
delete dependency, in a key-value prefix wipe, or in a loop someone wrote to
|
|
185
|
-
delete children by hand.
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
185
|
+
delete children by hand. Three rules run:
|
|
186
|
+
`undeclared-cascade` (a cascade declaration with no ownership declaration),
|
|
187
|
+
`delete-then-reinsert` (rebuild-by-delete over one table or collection), and
|
|
188
|
+
`handwritten-cascade` — the store-agnostic half, for stores that have **no
|
|
189
|
+
constraint to declare the cascade in the first place**:
|
|
190
|
+
|
|
191
|
+
| Store family | What the cascade looks like when nobody wrote it down |
|
|
192
|
+
|---|---|
|
|
193
|
+
| **MongoDB / Mongoose** | `deleteMany({ userId })` over a child collection — no FK action exists, so this code *is* the cascade |
|
|
194
|
+
| **Firestore** | a recursive `collectionGroup` / subcollection delete; missing a nested subcollection is the standard failure |
|
|
195
|
+
| **DynamoDB** | `DeleteItem` / `BatchWriteItem` driven by a parent key across tables; GSI/LSI are wiped with the table, items are not |
|
|
196
|
+
| **CouchDB** | documents selected and marked `_deleted: true` in one `bulkDocs` |
|
|
197
|
+
| **Redis** | a key-pattern wipe — `keys('session:<id>:*')` then `del`/`unlink`; the prefix *is* the parent |
|
|
198
|
+
| **Elasticsearch** | `deleteByQuery` on a parent field; no referential integrity exists to fall back on |
|
|
199
|
+
|
|
200
|
+
ORM-level declarations are matched too — `CascadeType.REMOVE`/`ALL`,
|
|
201
|
+
`orphanRemoval = true`, `DeleteBehavior.Cascade`, `onDelete: Cascade`
|
|
202
|
+
(Prisma/Sequelize), `on_delete=models.CASCADE` (Django),
|
|
203
|
+
`dependent: :destroy` / `:delete_all` (Rails), `cascade = "…delete…"`
|
|
204
|
+
(SQLAlchemy) — so the gate is not SQL DDL only.
|
|
205
|
+
|
|
206
|
+
A fire here asks for a **declaration**; it does not claim the ownership is
|
|
207
|
+
wrong, which stays a judgement in review. A delete of a row by its own identity
|
|
208
|
+
(`deleteOne({ _id: id })`) is not a cascade and does not fire — a rule that
|
|
209
|
+
fires on ordinary deletes gets switched off, and the coverage goes with it.
|
|
210
|
+
Projects add dialects with `model.extraCascadePatterns` and
|
|
211
|
+
`model.extraHandWrittenPatterns` rather than waiting for a release.
|
|
212
|
+
|
|
213
|
+
**Where the store has no constraint to inspect, the scan has nothing to attach
|
|
214
|
+
to and the invariant tests below are the only hard gate** — say which of the two
|
|
215
|
+
you relied on, and if the scan reported `scanned: 0`, say that too. A clean scan
|
|
216
|
+
means the tripwire caught nothing, not that the model is sound.
|
|
192
217
|
|
|
193
218
|
**Invariant and non-effect tests** — [06g-test-design.md](06g-test-design.md) §3:
|
|
194
219
|
survival tests, non-effect assertions, zero-row invariant queries, two-user
|
|
@@ -57,10 +57,17 @@ than accepted.
|
|
|
57
57
|
**A number without its contract is not a specification. Report it as incomplete;
|
|
58
58
|
do not accept it, and do not quietly drop it.**
|
|
59
59
|
|
|
60
|
+
**And a measurement without a target is not a verdict.** ASF does not decide a
|
|
61
|
+
consumer's SLA. `perf.target` is the one parameter in the table that ships **no
|
|
62
|
+
default**, and with nobody having declared one the output is the literal string
|
|
63
|
+
`no-default: needs a declared target` — never a silent pass, and never a number
|
|
64
|
+
ASF made up. A perfectly well-formed measurement that has nothing to compare
|
|
65
|
+
against has told you something and concluded nothing; say that.
|
|
66
|
+
|
|
60
67
|
| Claim type | Required, all of it |
|
|
61
68
|
|---|---|
|
|
62
|
-
| **Latency** | percentile · load it was measured at · sample count · environment · lab or field |
|
|
63
|
-
| **Throughput** | arrival model (closed-loop or open-loop) · duration · environment · sample count |
|
|
69
|
+
| **Latency** | percentile · load it was measured at · sample count · environment · lab or field · **the target it is judged against** |
|
|
70
|
+
| **Throughput** | arrival model (closed-loop or open-loop) · duration · environment · sample count · **the target it is judged against** |
|
|
64
71
|
| **Regression** | statistical comparison · interleaved randomized measurement · retained raw samples · observed CV · sample count · environment |
|
|
65
72
|
|
|
66
73
|
Wrong, and how it must be reported:
|