pi-aia-asf 0.13.1 → 0.13.3

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
@@ -7,6 +7,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.13.3] - 2026-10-16
11
+
12
+ ### Fixed
13
+
14
+ - **06j §5 had drifted back into enumerating products.** The section now carries
15
+ the store-agnostic *rule* — the defect is not SQL-shaped, and the distinction
16
+ that makes a hand-written cascade decidable is the **reference** (a row removing
17
+ itself vs one entity deciding the fate of another) — instead of a table of
18
+ store families and a list of ORM dialects. Which dialects the scanner
19
+ recognises is an implementation detail in `lib/code-health/model.mjs`, reported
20
+ per finding so you can see what it looked at; the standard states categories and
21
+ leaves the rest to `model.extraCascadePatterns` /
22
+ `model.extraHandWrittenPatterns`. A recognised dialect is not a coverage
23
+ promise: an unknown persistence shape is a `scanned: 0` you say out loud, not a
24
+ clean bill of health. The fixtures keep naming the concrete dialects — that is
25
+ where a pattern set belongs.
26
+
27
+ ## [0.13.2] - 2026-10-16
28
+
29
+ ### Added
30
+
31
+ - **The model gate is now store-agnostic in fact, not just in intent.** It knew
32
+ SQL DDL and ORM cascade spellings and nothing else, so in a document, key-value,
33
+ wide-column or search store — where there is no constraint to declare a cascade
34
+ in — the gate was blind to the exact defect it exists to catch. A third rule,
35
+ `handwritten-cascade`, catches the cascade that exists only as code someone
36
+ wrote: a multi-delete driven by a reference to *another* entity.
37
+ - Covered by name: **MongoDB/Mongoose** (`deleteMany({ userId })`, including
38
+ ES6 shorthand), **Firestore** (`collectionGroup` / recursive subcollection
39
+ delete, where the field name is an argument, not a key), **DynamoDB**
40
+ (`DeleteItem` / `BatchWriteItem` keyed by a parent), **CouchDB** (`_deleted:
41
+ true` in a `bulkDocs`), **Redis** (the key-pattern wipe — the prefix *is* the
42
+ parent), **Elasticsearch** (`deleteByQuery` on a parent field). ORM
43
+ declarations were already matched: `CascadeType.REMOVE`/`ALL`,
44
+ `orphanRemoval`, `DeleteBehavior.Cascade`, Prisma/Sequelize `onDelete:
45
+ Cascade`, Django `on_delete=models.CASCADE`, Rails `dependent: :destroy` /
46
+ `:delete_all`, SQLAlchemy `cascade = "…delete…"`.
47
+ - **Near-miss fixtures guard the false-positive side**, because a rule that
48
+ fires on ordinary deletes gets switched off and the coverage goes with it:
49
+ deleting a row by its own identity, `updateMany` by reference, a Redis `del`
50
+ of one known key, a Firestore query on a plain field, a CouchDB bulk insert,
51
+ an ES delete-by-query on a non-reference, a DynamoDB point read — all clean.
52
+ - A fire asks for a **declaration**, it does not claim the ownership is wrong;
53
+ `model.handWrittenCascade: false` switches it off and
54
+ `model.extraHandWrittenPatterns` adds a project's own dialect (an invalid
55
+ regex is dropped, never fatal).
56
+ - `test-model-gate.mjs` grew from 73 to **92 checks**, and the assertion that
57
+ recorded this as a *documented gap* ("a hand-rolled document-store cascade is
58
+ not caught — invariant tests carry it") is now inverted to assert it **is**
59
+ caught. The production shape that started this — `deleteMany({ question_id:
60
+ { $in: ids } })` over a child collection — is the fixture.
61
+
10
62
  ## [0.13.1] - 2026-10-16
11
63
 
12
64
  ### Fixed
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-aia-asf",
3
- "version": "0.13.1",
3
+ "version": "0.13.3",
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",
@@ -179,16 +179,38 @@ requirement: "is this ownership correct?" is judgment and stays in review, while
179
179
  "does this file declare a cascade with no `asf-model:` tag?" is measurable.
180
180
 
181
181
  **It is a tripwire, not an authority — and it is store-agnostic by design.** The
182
- defect is not SQL-shaped: an instance inheriting its parent's lifecycle happens
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. The scan recognises a small illustrative set of those
186
- spellings and a project adds its own dialect with `model.extraCascadePatterns`
187
- rather than waiting for a release. **Where the store has no constraint to
188
- inspect, the scan has nothing to attach to and the invariant tests below are the
189
- only hard gate** — say which of the two you relied on, and if the scan reported
190
- `scanned: 0`, say that too. A clean scan means the tripwire caught nothing, not
191
- that the model is sound.
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 shape a cascade takes in a store that has **no
189
+ constraint to declare it in**, where the decision exists only as code someone
190
+ wrote: a multi-delete driven by a reference to *another* entity.
191
+
192
+ The distinction that makes `handwritten-cascade` decidable rather than
193
+ judgemental is the **reference**. `deleteOne({ _id: id })` is a row removing
194
+ itself; `deleteMany({ userId })` is one entity deciding the fate of another. The
195
+ first does not fire — a rule that fires on ordinary deletes gets switched off and
196
+ the coverage goes with it, which is why the near-miss fixtures matter more than
197
+ the fire fixtures. A fire asks for a **declaration**; it does not claim the
198
+ ownership is wrong, and that judgement stays in review.
199
+
200
+ **The pattern set is illustrative and the standard does not enumerate products.**
201
+ The categories above are the standard; the dialects the scanner happens to
202
+ recognise are an implementation detail in `lib/code-health/model.mjs`, reported
203
+ per finding so you can see what it looked at. A project whose store is not in
204
+ that set adds its own spelling with `model.extraCascadePatterns` /
205
+ `model.extraHandWrittenPatterns` rather than waiting for a release — and a
206
+ recognised dialect is not a coverage promise. If your persistence layer speaks a
207
+ shape the scanner does not know, that is a `scanned: 0` you must say out loud,
208
+ not a clean bill of health.
209
+
210
+ **Where the store has no constraint to inspect, the scan has nothing to attach
211
+ to and the invariant tests below are the only hard gate** — say which of the two
212
+ you relied on, and if the scan reported `scanned: 0`, say that too. A clean scan
213
+ means the tripwire caught nothing, not that the model is sound.
192
214
 
193
215
  **Invariant and non-effect tests** — [06g-test-design.md](06g-test-design.md) §3:
194
216
  survival tests, non-effect assertions, zero-row invariant queries, two-user