@holmes-lab/holmes-kit 0.1.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.
- package/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.md +102 -0
- package/bin/holmes-hook-antigravity.js +31 -0
- package/bin/holmes-kit.js +23 -0
- package/bin/holmes-mcp.js +34 -0
- package/bin/holmes-stop-antigravity.js +29 -0
- package/dist/.build-id +1 -0
- package/dist/holmes/cli/agents.js +168 -0
- package/dist/holmes/cli/doctor.js +625 -0
- package/dist/holmes/cli/gitignore-merge.js +84 -0
- package/dist/holmes/cli/governed-precondition.js +157 -0
- package/dist/holmes/cli/index.js +384 -0
- package/dist/holmes/cli/init.js +462 -0
- package/dist/holmes/cli/playbook-skills.js +711 -0
- package/dist/holmes/cli/roles-readme.js +134 -0
- package/dist/holmes/cli/settings-merge.js +122 -0
- package/dist/holmes/config/config.js +70 -0
- package/dist/holmes/context/bundler.js +114 -0
- package/dist/holmes/context/render.js +29 -0
- package/dist/holmes/context/tiers.js +110 -0
- package/dist/holmes/context/tokens.js +8 -0
- package/dist/holmes/cpg/cpg-scanner.js +213 -0
- package/dist/holmes/cpg/hash-cache.js +86 -0
- package/dist/holmes/cpg/language-parser-walk.js +917 -0
- package/dist/holmes/cpg/language-parser-worker.js +81 -0
- package/dist/holmes/cpg/language-parser.js +234 -0
- package/dist/holmes/cpg/scan-cache.js +108 -0
- package/dist/holmes/cpg/source-path.js +44 -0
- package/dist/holmes/cpg/test-files.js +84 -0
- package/dist/holmes/governance/constitution-debt.js +73 -0
- package/dist/holmes/governance/constitution-report.js +25 -0
- package/dist/holmes/governance/constitution.js +129 -0
- package/dist/holmes/governance/identity.js +30 -0
- package/dist/holmes/governance/ledger-lock.js +165 -0
- package/dist/holmes/governance/ledger-store.conformance.js +90 -0
- package/dist/holmes/governance/ledger-store.js +106 -0
- package/dist/holmes/governance/progress-ledger.js +83 -0
- package/dist/holmes/governance/provenance-chain.js +365 -0
- package/dist/holmes/governance/provenance-ledger.js +0 -0
- package/dist/holmes/governance/provenance-schema.js +47 -0
- package/dist/holmes/governance/replica-id.js +106 -0
- package/dist/holmes/governance/role-policy.js +137 -0
- package/dist/holmes/governance/trust-score.js +43 -0
- package/dist/holmes/guardrail/anchors.js +31 -0
- package/dist/holmes/guardrail/blind-spots.js +38 -0
- package/dist/holmes/guardrail/decision-ledger.js +107 -0
- package/dist/holmes/guardrail/executable-artifact.js +129 -0
- package/dist/holmes/guardrail/governance-history.js +101 -0
- package/dist/holmes/guardrail/phase.js +169 -0
- package/dist/holmes/guardrail/risk-classifier.js +450 -0
- package/dist/holmes/guardrail/risk-gate.js +160 -0
- package/dist/holmes/guardrail/risk-types.js +6 -0
- package/dist/holmes/guardrail/tspec-state.js +392 -0
- package/dist/holmes/guardrail/write-target.js +224 -0
- package/dist/holmes/hooks/adapters/antigravity.js +194 -0
- package/dist/holmes/hooks/pre-tool-use.js +1262 -0
- package/dist/holmes/hooks/stop.js +416 -0
- package/dist/holmes/mcp/basis.js +162 -0
- package/dist/holmes/mcp/handlers.js +1831 -0
- package/dist/holmes/mcp/server.js +71 -0
- package/dist/holmes/mcp/stdio-client.js +165 -0
- package/dist/holmes/mcp/supervisor.js +178 -0
- package/dist/holmes/mcp/tool-schemas.js +394 -0
- package/dist/holmes/mcp/validate-args.js +281 -0
- package/dist/holmes/messages/registry.js +50 -0
- package/dist/holmes/project/baseline.js +210 -0
- package/dist/holmes/project/change-source.js +233 -0
- package/dist/holmes/project/ignore.js +145 -0
- package/dist/holmes/project/root.js +113 -0
- package/dist/holmes/reverse/anchor.js +162 -0
- package/dist/holmes/reverse/cluster.js +187 -0
- package/dist/holmes/reverse/draft.js +151 -0
- package/dist/holmes/reverse/dynamic-wiring.js +47 -0
- package/dist/holmes/reverse/scan.js +194 -0
- package/dist/holmes/reverse/surface.js +154 -0
- package/dist/holmes/reverse/test-map.js +263 -0
- package/dist/holmes/review/coverage.js +33 -0
- package/dist/holmes/review/findings.js +123 -0
- package/dist/holmes/review/package.js +40 -0
- package/dist/holmes/review/review-targets.js +92 -0
- package/dist/holmes/review/scope.js +57 -0
- package/dist/holmes/review/test-evidence.js +77 -0
- package/dist/holmes/review/test-runner.js +572 -0
- package/dist/holmes/rtm/dataflow-taint.js +262 -0
- package/dist/holmes/rtm/gap-analyzer.js +27 -0
- package/dist/holmes/rtm/git-changes.js +72 -0
- package/dist/holmes/rtm/incremental.js +45 -0
- package/dist/holmes/rtm/localize.js +100 -0
- package/dist/holmes/rtm/rtm-builder.js +191 -0
- package/dist/holmes/rtm/rtm-check.js +89 -0
- package/dist/holmes/rtm/rtm-graph.js +232 -0
- package/dist/holmes/rtm/taint.js +92 -0
- package/dist/holmes/rtm/test-scope.js +336 -0
- package/dist/holmes/spec/approval-blockers.js +204 -0
- package/dist/holmes/spec/breaking-change.js +89 -0
- package/dist/holmes/spec/legacy-format.js +87 -0
- package/dist/holmes/spec/spec-digest.js +71 -0
- package/dist/holmes/spec/spec-parser.js +106 -0
- package/dist/holmes/spec/spec-store.conformance.js +118 -0
- package/dist/holmes/spec/spec-store.js +331 -0
- package/dist/holmes/spec/spec-types.js +177 -0
- package/dist/holmes/spec/validator.js +280 -0
- package/package.json +76 -0
- package/playbooks/adopt/PLAYBOOK.md +125 -0
- package/playbooks/author-slice/PLAYBOOK.md +119 -0
- package/playbooks/promote-slice/PLAYBOOK.md +134 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.COHESION_MIN = exports.MAX_FILES = exports.MIN_FILES = void 0;
|
|
37
|
+
exports.clusterFiles = clusterFiles;
|
|
38
|
+
// @implements A-SPEC-126
|
|
39
|
+
const path = __importStar(require("node:path"));
|
|
40
|
+
/**
|
|
41
|
+
* Partition a scanned brownfield tree into A-SPEC-sized candidate slices.
|
|
42
|
+
*
|
|
43
|
+
* Directory locality does the first cut; call cohesion decides whether a directory is ONE slice or a
|
|
44
|
+
* bag of independent modules. Pure and deterministic: same input files -> byte-identical output, so a
|
|
45
|
+
* proposed partition can be diffed and re-checked rather than taken on trust.
|
|
46
|
+
*/
|
|
47
|
+
/** Directory names that make a cluster a T-SPEC's business rather than an A-SPEC candidate. */
|
|
48
|
+
const TEST_DIR_RE = /(^|\/)(tests?|__tests__|specs?|e2e|fixtures)(\/|$)/;
|
|
49
|
+
/** A directory with fewer files than this is folded into its nearest ancestor before classification. */
|
|
50
|
+
exports.MIN_FILES = 3;
|
|
51
|
+
/** Above this a cohesive cluster is flagged `oversized` — reported, never split (H-SPEC-126). */
|
|
52
|
+
exports.MAX_FILES = 25;
|
|
53
|
+
/**
|
|
54
|
+
* Cohesion threshold: ANY resolved internal call makes a directory cohesive.
|
|
55
|
+
*
|
|
56
|
+
* Derived, not chosen. Re-measured from the graph's own `calls` edges across a Python target and this
|
|
57
|
+
* repository, independent directories measure 0.00 and every cohesive one at least 0.17 — the classes
|
|
58
|
+
* separate at exactly zero. Zero is safe as a cutoff precisely because edge resolution is
|
|
59
|
+
* precision-first: an ambiguous callee resolves to nothing, so a single internal edge is evidence
|
|
60
|
+
* rather than a guess. Exported and named so a target with an unusual layout can raise it.
|
|
61
|
+
*/
|
|
62
|
+
exports.COHESION_MIN = 1;
|
|
63
|
+
/**
|
|
64
|
+
* Extensions whose call edges the scanner can actually extract (`extractEdgesFromTree` implements
|
|
65
|
+
* TS/JS and Python only).
|
|
66
|
+
*
|
|
67
|
+
* This distinction is load-bearing. For Go/Java/Rust/C#/C++ a scan yields zero edges because the
|
|
68
|
+
* analyzer cannot see calls — not because the files are independent. Reading that zero as
|
|
69
|
+
* independence would split every such package per file and make each slice's blast radius wrong by
|
|
70
|
+
* construction, which is the exact error the split rule exists to prevent. Absence of evidence is
|
|
71
|
+
* reported as `unmeasured`, and an unmeasured cluster is left whole for a human to judge.
|
|
72
|
+
*/
|
|
73
|
+
const EDGE_CAPABLE_RE = /\.(ts|mts|cts|tsx|jsx|js|mjs|cjs|py)$/;
|
|
74
|
+
/** Byte-stable comparator. `localeCompare` is locale-dependent and would break determinism. */
|
|
75
|
+
const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
76
|
+
/**
|
|
77
|
+
* Resolve a callee name to the file that defines it, exactly as rtm-builder does, so cohesion
|
|
78
|
+
* measures the SAME edges the graph holds: an own-file definition wins, a uniquely-owned name
|
|
79
|
+
* resolves, and an ambiguous one resolves to nothing rather than to a guess.
|
|
80
|
+
*/
|
|
81
|
+
function makeResolver(files) {
|
|
82
|
+
const owner = new Map();
|
|
83
|
+
for (const f of files) {
|
|
84
|
+
for (const s of f.symbols) {
|
|
85
|
+
if (!owner.has(s.qualifiedName))
|
|
86
|
+
owner.set(s.qualifiedName, new Set());
|
|
87
|
+
owner.get(s.qualifiedName).add(f.sourcePath);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// A bare callee (`save`) can only be matched against the LAST segment of a qualified name
|
|
91
|
+
// (`Store.save`). A last segment owned by more than one name is ambiguous and resolves to nothing.
|
|
92
|
+
const byLast = new Map();
|
|
93
|
+
for (const [qn, paths] of owner) {
|
|
94
|
+
const last = qn.includes('.') ? qn.slice(qn.lastIndexOf('.') + 1) : qn;
|
|
95
|
+
if (byLast.has(last))
|
|
96
|
+
byLast.set(last, null);
|
|
97
|
+
else
|
|
98
|
+
byLast.set(last, paths);
|
|
99
|
+
}
|
|
100
|
+
return (fromPath, to) => {
|
|
101
|
+
const paths = owner.get(to) ?? byLast.get(to) ?? null;
|
|
102
|
+
if (!paths)
|
|
103
|
+
return null;
|
|
104
|
+
if (paths.has(fromPath))
|
|
105
|
+
return fromPath;
|
|
106
|
+
return paths.size === 1 ? [...paths][0] : null;
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function clusterFiles(files, opts = {}) {
|
|
110
|
+
const { minFiles = exports.MIN_FILES, maxFiles = exports.MAX_FILES, cohesionMin = exports.COHESION_MIN } = opts;
|
|
111
|
+
const resolve = makeResolver(files);
|
|
112
|
+
const byDir = new Map();
|
|
113
|
+
for (const f of files) {
|
|
114
|
+
const d = path.posix.dirname(f.sourcePath);
|
|
115
|
+
if (!byDir.has(d))
|
|
116
|
+
byDir.set(d, []);
|
|
117
|
+
byDir.get(d).push(f);
|
|
118
|
+
}
|
|
119
|
+
// Fold undersized directories into the nearest ancestor that holds files. Deepest first, so a
|
|
120
|
+
// chain of thin directories collapses upward in one pass. The tie-break keeps the traversal order
|
|
121
|
+
// independent of the order files arrived in.
|
|
122
|
+
const dirs = [...byDir.keys()].sort((a, b) => b.length - a.length || cmp(a, b));
|
|
123
|
+
for (const d of dirs) {
|
|
124
|
+
const group = byDir.get(d);
|
|
125
|
+
if (!group || group.length >= minFiles || d === '.')
|
|
126
|
+
continue;
|
|
127
|
+
let parent = path.posix.dirname(d);
|
|
128
|
+
while (parent !== '.' && !byDir.has(parent))
|
|
129
|
+
parent = path.posix.dirname(parent);
|
|
130
|
+
const target = byDir.has(parent) ? parent : '.';
|
|
131
|
+
if (target === d)
|
|
132
|
+
continue;
|
|
133
|
+
// Folding is a SIZE rule and must never override the KIND rule. A two-file `fixtures/` beside a
|
|
134
|
+
// cohesive module would otherwise be absorbed into it and drafted as production code — and the
|
|
135
|
+
// scanner's own test-file filter does not catch `fixtures/`. An undersized test directory simply
|
|
136
|
+
// stays its own (test) cluster, which is where it gets reported separately anyway.
|
|
137
|
+
if (TEST_DIR_RE.test(d) !== TEST_DIR_RE.test(target))
|
|
138
|
+
continue;
|
|
139
|
+
if (!byDir.has(target))
|
|
140
|
+
byDir.set(target, []);
|
|
141
|
+
byDir.get(target).push(...group);
|
|
142
|
+
byDir.delete(d);
|
|
143
|
+
}
|
|
144
|
+
const out = [];
|
|
145
|
+
for (const [dir, group] of byDir) {
|
|
146
|
+
const inDir = new Set(group.map((f) => f.sourcePath));
|
|
147
|
+
let internalCalls = 0;
|
|
148
|
+
for (const f of group) {
|
|
149
|
+
for (const e of f.edges ?? []) {
|
|
150
|
+
if (e.rel !== 'calls')
|
|
151
|
+
continue;
|
|
152
|
+
const target = resolve(f.sourcePath, e.to);
|
|
153
|
+
// A call that lands back in its own file says nothing about whether the file belongs with
|
|
154
|
+
// its neighbours, so only cross-file, same-directory calls count.
|
|
155
|
+
if (target && target !== f.sourcePath && inDir.has(target))
|
|
156
|
+
internalCalls++;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const measurable = group.some((f) => EDGE_CAPABLE_RE.test(f.sourcePath));
|
|
160
|
+
const density = internalCalls / group.length;
|
|
161
|
+
const cohesion = !measurable ? 'unmeasured' : internalCalls >= cohesionMin ? 'cohesive' : 'independent';
|
|
162
|
+
const isTest = TEST_DIR_RE.test(dir);
|
|
163
|
+
if (cohesion === 'independent') {
|
|
164
|
+
// A bag of independent modules: each file is its own candidate. Folding N unrelated modules
|
|
165
|
+
// into one slice makes every blast-radius answer wrong by roughly N — and N does not have to be
|
|
166
|
+
// large for that to be wrong, which is why this is NOT gated on size. Directories too small to
|
|
167
|
+
// deserve a slice were already folded into a parent above.
|
|
168
|
+
for (const f of [...group].sort((a, b) => cmp(a.sourcePath, b.sourcePath))) {
|
|
169
|
+
out.push({
|
|
170
|
+
dir, files: [f.sourcePath], symbols: f.symbols.length,
|
|
171
|
+
internalCalls: 0, density, cohesion, split: 'per-file', isTest, oversized: false, testFiles: [],
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
out.push({
|
|
177
|
+
dir,
|
|
178
|
+
files: group.map((f) => f.sourcePath).sort(cmp),
|
|
179
|
+
symbols: group.reduce((n, f) => n + f.symbols.length, 0),
|
|
180
|
+
internalCalls, density, cohesion, split: 'whole', isTest,
|
|
181
|
+
oversized: group.length > maxFiles, testFiles: [],
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// Total deterministic order: repeated runs on an unchanged tree must be byte-identical.
|
|
186
|
+
return out.sort((a, b) => cmp(`${a.dir} ${a.files[0]}`, `${b.dir} ${b.files[0]}`));
|
|
187
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.INFERRED = void 0;
|
|
4
|
+
exports.clusterKeyOf = clusterKeyOf;
|
|
5
|
+
exports.nextIdBase = nextIdBase;
|
|
6
|
+
exports.draftSpecs = draftSpecs;
|
|
7
|
+
const surface_1 = require("./surface");
|
|
8
|
+
/**
|
|
9
|
+
* Draft the H-SPEC/A-SPEC/T-SPEC documents for one recovered cluster.
|
|
10
|
+
*
|
|
11
|
+
* Pure, deterministic, and deliberately INCOMPLETE. Structural sections are filled from scan
|
|
12
|
+
* evidence; every prose section is left as an explicit `TODO` for a human, because the only thing a
|
|
13
|
+
* scan can testify to is what the code does — never why it exists. `status: 'draft'` is hard-coded
|
|
14
|
+
* rather than defaulted, so no argument can promote a description into a normative spec.
|
|
15
|
+
*
|
|
16
|
+
* NO CODE PATH HERE CREATES A REQ. A requirement is business intent, which is not in the code;
|
|
17
|
+
* `parentReqId` is mandatory and a missing one is a refusal, not a prompt to invent one.
|
|
18
|
+
*/
|
|
19
|
+
const DESCRIPTIVE_NOTE = 'NOTE: this spec was REVERSE-ENGINEERED from existing code. It describes what the code does today, ' +
|
|
20
|
+
'not what it should do. Every prose section below is a TODO for a human; approving this document ' +
|
|
21
|
+
'is the point at which a description becomes a normative specification.';
|
|
22
|
+
const STRUCTURAL = '<!-- structural: derived from scan evidence -->';
|
|
23
|
+
/**
|
|
24
|
+
* Exported so the approval-blocker judge can recognise an untouched draft by the marker this file
|
|
25
|
+
* plants, rather than re-typing the literal. A copy would drift silently the first time this string
|
|
26
|
+
* changes — and a silent divergence is the very class of defect REQ-182 exists to close.
|
|
27
|
+
*/
|
|
28
|
+
exports.INFERRED = '<!-- inferred: not recoverable from code — a human writes this -->';
|
|
29
|
+
const structural = (body) => `${body}\n\n${STRUCTURAL}`;
|
|
30
|
+
const todo = (prompt) => `TODO — ${prompt}\n\n${exports.INFERRED}`;
|
|
31
|
+
/**
|
|
32
|
+
* Scan evidence followed by a TODO — the section is FILLED but still BLOCKS approval.
|
|
33
|
+
*
|
|
34
|
+
* This is the whole point of A-SPEC-181's split. Stating what the scanner saw turns the human's job
|
|
35
|
+
* from writing a blank section into editing a draft, which is where the cost actually was. It does
|
|
36
|
+
* not turn evidence into a decision, so the `TODO`/`INFERRED` pair REQ-182 judges on stays planted:
|
|
37
|
+
* a symbol dump must never acquire a seal by looking full.
|
|
38
|
+
*/
|
|
39
|
+
const evidenceThenTodo = (body, prompt) => `${body}\n\n${STRUCTURAL}\n\n${todo(prompt)}`;
|
|
40
|
+
/** The identity of a cluster: its directory, or the file itself when the directory split per file. */
|
|
41
|
+
function clusterKeyOf(cluster) {
|
|
42
|
+
return cluster.split === 'per-file' ? cluster.files[0] : cluster.dir;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The first id number above every number already in use, across all spec kinds.
|
|
46
|
+
*
|
|
47
|
+
* Kind-agnostic on purpose: the three drafted documents share one number (H-SPEC-N / A-SPEC-N /
|
|
48
|
+
* T-SPEC-N), so the base must be free for every kind at once. Deterministic given the same spec set.
|
|
49
|
+
*/
|
|
50
|
+
function nextIdBase(existingIds) {
|
|
51
|
+
let max = 99;
|
|
52
|
+
for (const id of existingIds) {
|
|
53
|
+
const m = /-(\d{3,})(?:\.\d+)?$/.exec(id);
|
|
54
|
+
if (m)
|
|
55
|
+
max = Math.max(max, Number(m[1]));
|
|
56
|
+
}
|
|
57
|
+
return max + 1;
|
|
58
|
+
}
|
|
59
|
+
function draftSpecs(cluster, parentReqId, idBase, evidence) {
|
|
60
|
+
if (typeof parentReqId !== 'string' || parentReqId.trim() === '') {
|
|
61
|
+
return {
|
|
62
|
+
ok: false,
|
|
63
|
+
reason: 'parentReqId is required. A REQ states business intent, which does not exist in the code and ' +
|
|
64
|
+
'cannot be recovered from it — write the REQ first, then re-run with its id.',
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
if (cluster.isTest) {
|
|
68
|
+
return { ok: false, reason: `cluster "${cluster.dir}" is rooted in a test directory — tests implement nothing, so no A-SPEC is drafted for them` };
|
|
69
|
+
}
|
|
70
|
+
const base = String(idBase);
|
|
71
|
+
if (!/^\d{3,}$/.test(base)) {
|
|
72
|
+
return { ok: false, reason: `id base "${base}" must be at least three digits to form valid spec ids` };
|
|
73
|
+
}
|
|
74
|
+
const req = parentReqId.trim();
|
|
75
|
+
const hspecId = `H-SPEC-${base}`;
|
|
76
|
+
const aspecId = `A-SPEC-${base}`;
|
|
77
|
+
const tspecId = `T-SPEC-${base}`;
|
|
78
|
+
const key = clusterKeyOf(cluster);
|
|
79
|
+
const testFiles = cluster.testFiles ?? [];
|
|
80
|
+
// Test paths belong in Files to Touch as much as production ones do: they are anchored to this
|
|
81
|
+
// A-SPEC (that anchor is what makes per-A-SPEC test evidence exist), and the gate's scope check
|
|
82
|
+
// would otherwise refuse the very edits the anchor invites.
|
|
83
|
+
const fileList = [
|
|
84
|
+
...cluster.files.map((f) => `- \`${f}\``),
|
|
85
|
+
...(testFiles.length ? ['', 'Tests attached to this slice by name evidence:', ...testFiles.map((f) => `- \`${f}\``)] : []),
|
|
86
|
+
].join('\n');
|
|
87
|
+
const testsLine = testFiles.length
|
|
88
|
+
? `${testFiles.length} existing test file(s) cover this slice and are anchored to it:\n\n${testFiles.map((f) => `- \`${f}\``).join('\n')}`
|
|
89
|
+
: 'No existing test file could be attached to this slice by name evidence — either it has none, or their names do not identify their subject. Until one is anchored here, this slice has no per-A-SPEC test evidence.';
|
|
90
|
+
const cohesionLine = `Cohesion: ${cluster.internalCalls} resolved internal call(s) across ${cluster.files.length} file(s) ` +
|
|
91
|
+
`(density ${cluster.density.toFixed(2)}) → ${cluster.cohesion}, kept as "${cluster.split}".` +
|
|
92
|
+
(cluster.cohesion === 'unmeasured'
|
|
93
|
+
? ' The scanner cannot extract call edges for this language, so the absence of calls is NOT evidence of independence — a human must confirm the boundary.'
|
|
94
|
+
: '');
|
|
95
|
+
// Recorded on every emitted document: `reverse_cluster` is the identity a re-run matches against so
|
|
96
|
+
// it never drafts the same cluster twice, and `reverse_engineered` marks the document as descriptive.
|
|
97
|
+
const provenance = { reverse_engineered: true, reverse_cluster: key, reverse_parent_req: req };
|
|
98
|
+
const specs = [
|
|
99
|
+
{
|
|
100
|
+
id: hspecId, type: 'H-SPEC', title: `Recovered slice: ${key}`, status: 'draft', dependsOn: [req],
|
|
101
|
+
frontmatter: { req_type: 'functional', owner: 'TODO', ...provenance },
|
|
102
|
+
sections: {
|
|
103
|
+
Intent: `${DESCRIPTIVE_NOTE}\n\nTODO — state what this slice is FOR. Reverse engineering recovers what the code DOES; intent lives with people, not in the source.\n\n${exports.INFERRED}`,
|
|
104
|
+
'Scope (In / Out)': structural(`In: ${cluster.files.length} file(s) under \`${cluster.dir}\`.\n\n${fileList}\n\nOut: everything not listed above.`),
|
|
105
|
+
'Design Overview': structural(cohesionLine),
|
|
106
|
+
'Interfaces / Contracts': evidence
|
|
107
|
+
? evidenceThenTodo((0, surface_1.renderSurface)(evidence.surface), '위 목록에서 공개 표면이 아닌 것을 지우고, 남은 것이 무엇을 약속하는지 적어라. ' +
|
|
108
|
+
'중첩 함수와 밑줄로 시작하는 이름은 이미 제외돼 있다.')
|
|
109
|
+
: todo("name the contracts this slice exposes. The scan counts symbols; it cannot tell which are a public surface."),
|
|
110
|
+
'Acceptance Criteria': todo('state what "correct" means for this slice. Existing behavior is evidence, not a criterion.'),
|
|
111
|
+
'Non-Functional': todo('state the latency/throughput/availability obligations this slice actually carries.'),
|
|
112
|
+
Assumptions: structural(cohesionLine),
|
|
113
|
+
'Open Questions': todo('list what the code could not answer — dynamic wiring, config-driven behavior, external contracts.'),
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
id: aspecId, type: 'A-SPEC', title: `Recovered slice implementation: ${key}`, status: 'draft', dependsOn: [hspecId],
|
|
118
|
+
frontmatter: { slice: key, priority: 'TODO', independent_test: false, ...provenance },
|
|
119
|
+
sections: {
|
|
120
|
+
Objective: `${DESCRIPTIVE_NOTE}\n\nTODO — describe the objective this code serves.\n\n${exports.INFERRED}`,
|
|
121
|
+
'Inputs / Outputs': todo('describe the inputs and outputs. The scan sees symbols, not data contracts.'),
|
|
122
|
+
Behavior: todo('describe the behavior this code implements today, then decide whether it is the behavior you want.'),
|
|
123
|
+
'Test Points': evidence
|
|
124
|
+
? evidenceThenTodo((0, surface_1.renderTestPoints)(evidence), '위 테스트가 이미 고정하는 성질과, 아직 아무도 고정하지 않은 성질을 갈라 적어라. ' +
|
|
125
|
+
'테스트 파일의 존재는 그 성질이 무엇인지 말해주지 않는다.')
|
|
126
|
+
: todo('list the properties worth pinning. Existing tests, if any, are listed in the scan report.'),
|
|
127
|
+
'Files to Touch': structural(fileList),
|
|
128
|
+
// Filled unconditionally: it needs only the cluster's own files and attached tests, and its
|
|
129
|
+
// three clauses are checkable as written — so this section does NOT keep a TODO and does not
|
|
130
|
+
// block approval. Clause 3 survives even at zero tests; see renderDoneWhen.
|
|
131
|
+
'Done When': structural((0, surface_1.renderDoneWhen)(cluster.files, testFiles)),
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
id: tspecId, type: 'T-SPEC', title: `Recovered slice tests: ${key}`, status: 'draft', dependsOn: [aspecId],
|
|
136
|
+
frontmatter: { coverage: { normal: false, corner: false, negative: false, boundary: false }, ...provenance },
|
|
137
|
+
sections: {
|
|
138
|
+
'Normal Cases': `${DESCRIPTIVE_NOTE}\n\n${testsLine}\n\nTODO — write the Given/When/Then cases. Approving a T-SPEC requires all four quadrants to carry a real scenario.\n\n${exports.INFERRED}`,
|
|
139
|
+
'Corner Cases': todo('write the corner-case scenarios.'),
|
|
140
|
+
'Negative Cases': todo('write the negative-case scenarios.'),
|
|
141
|
+
'Boundary Cases': todo('write the boundary-case scenarios.'),
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
];
|
|
145
|
+
return {
|
|
146
|
+
ok: true,
|
|
147
|
+
specs,
|
|
148
|
+
anchorPlan: [...cluster.files, ...testFiles].map((file) => ({ file, aspec: aspecId })),
|
|
149
|
+
clusterKey: key,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// @implements A-SPEC-126
|
|
3
|
+
/**
|
|
4
|
+
* Detect the wiring forms a syntactic scan cannot follow — DI decorators, dynamic `require`/`import`,
|
|
5
|
+
* string-key dispatch, `eval`-like execution, config-driven wiring.
|
|
6
|
+
*
|
|
7
|
+
* REPORTED, NEVER RESOLVED. An indicator says "there is a relationship here that static evidence
|
|
8
|
+
* cannot recover"; it never becomes an edge or a cluster membership. The correct outcome for such a
|
|
9
|
+
* file is that it stays visibly unresolved, not that it gets attached to a guessed slice.
|
|
10
|
+
*
|
|
11
|
+
* The pattern sets are LANGUAGE-SCOPED and only exist for languages that were calibrated. Applying
|
|
12
|
+
* one language's regexes to another put nearly half of one subject's files under two indicators
|
|
13
|
+
* during calibration — and an indicator firing on most of a codebase teaches the reader to ignore it,
|
|
14
|
+
* which is worse than not shipping it. Languages with no calibrated set report nothing, and
|
|
15
|
+
* `dynamicWiringSupported` makes that gap visible instead of letting a silent zero read as "clean".
|
|
16
|
+
*/
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.dynamicWiringSupported = dynamicWiringSupported;
|
|
19
|
+
exports.detectDynamicWiring = detectDynamicWiring;
|
|
20
|
+
const PY = [
|
|
21
|
+
['dynamic-import', /\b(?:importlib\.import_module|__import__)\s*\(/m],
|
|
22
|
+
['di-decorator', /(?:^|\n)\s*@(?:inject|provide|singleton|component|service|app\.(?:route|get|post|put|delete)|router\.(?:get|post|put|delete))\b/mi],
|
|
23
|
+
['string-key-dispatch', /\b(?:getattr|setattr)\s*\(|globals\(\)\[|locals\(\)\[/m],
|
|
24
|
+
['eval-like', /(?:^|[^.\w])(?:eval|exec)\s*\(/m],
|
|
25
|
+
['config-driven', /\b(?:yaml\.(?:safe_)?load|json\.load|toml\.load|configparser|load_dotenv|ConfigParser)\s*\(?/m],
|
|
26
|
+
];
|
|
27
|
+
const JS = [
|
|
28
|
+
// A `require`/`import` whose argument is not a literal — the literal case is exactly what the
|
|
29
|
+
// scanner CAN follow, so flagging it would be noise.
|
|
30
|
+
['dynamic-import', /(?:^|[^.\w])(?:require|import)\s*\(\s*(?!['"`)])/m],
|
|
31
|
+
['di-decorator', /(?:^|\n)\s*@(?:Injectable|Component|Autowired|Inject|Service|Controller|Module|NgModule)\b/m],
|
|
32
|
+
['string-key-dispatch', /\[\s*[a-zA-Z_$][\w$]*\s*\]\s*\(/m],
|
|
33
|
+
['eval-like', /(?:^|[^.\w])eval\s*\(|new\s+Function\s*\(/m],
|
|
34
|
+
['config-driven', /\b(?:readFileSync|readFile|loadConfig|dotenv\.config|cosmiconfig)\s*\(/m],
|
|
35
|
+
];
|
|
36
|
+
const PY_RE = /\.py$/;
|
|
37
|
+
const JS_RE = /\.(ts|mts|cts|tsx|jsx|js|mjs|cjs)$/;
|
|
38
|
+
/** True when this file's language has a calibrated indicator set. */
|
|
39
|
+
function dynamicWiringSupported(file) {
|
|
40
|
+
return PY_RE.test(file) || JS_RE.test(file);
|
|
41
|
+
}
|
|
42
|
+
function detectDynamicWiring(code, file) {
|
|
43
|
+
const set = PY_RE.test(file) ? PY : JS_RE.test(file) ? JS : null;
|
|
44
|
+
if (!set)
|
|
45
|
+
return []; // uncalibrated language — see dynamicWiringSupported
|
|
46
|
+
return set.filter(([, re]) => re.test(code)).map(([k]) => k).sort();
|
|
47
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.candidates = candidates;
|
|
37
|
+
exports.reverseScan = reverseScan;
|
|
38
|
+
// @implements A-SPEC-126
|
|
39
|
+
const fs = __importStar(require("node:fs"));
|
|
40
|
+
const path = __importStar(require("node:path"));
|
|
41
|
+
const node_child_process_1 = require("node:child_process");
|
|
42
|
+
const cpg_scanner_1 = require("../cpg/cpg-scanner");
|
|
43
|
+
const test_files_1 = require("../cpg/test-files");
|
|
44
|
+
const cluster_1 = require("./cluster");
|
|
45
|
+
const dynamic_wiring_1 = require("./dynamic-wiring");
|
|
46
|
+
const test_map_1 = require("./test-map");
|
|
47
|
+
const surface_1 = require("./surface");
|
|
48
|
+
// @implements A-SPEC-128
|
|
49
|
+
const ignore_1 = require("../project/ignore");
|
|
50
|
+
const draft_1 = require("./draft");
|
|
51
|
+
const SCANNABLE_RE = new RegExp(`(${cpg_scanner_1.SCANNABLE_EXTENSIONS.map((e) => e.replace('.', '\\.')).join('|')})$`);
|
|
52
|
+
/** Walk a non-git tree for test files, mirroring the scanner's own vendor-directory skips. */
|
|
53
|
+
function walkTestFiles(root) {
|
|
54
|
+
const out = [];
|
|
55
|
+
const walk = (dir) => {
|
|
56
|
+
let entries;
|
|
57
|
+
try {
|
|
58
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
for (const e of entries) {
|
|
64
|
+
if (cpg_scanner_1.SKIP.has(e.name))
|
|
65
|
+
continue;
|
|
66
|
+
const abs = path.join(dir, e.name);
|
|
67
|
+
const rel = path.relative(root, abs).split(path.sep).join('/');
|
|
68
|
+
if (e.isDirectory())
|
|
69
|
+
walk(abs);
|
|
70
|
+
else if (SCANNABLE_RE.test(e.name) && (0, test_files_1.isTestFile)(rel))
|
|
71
|
+
out.push(rel);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
walk(root);
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Only git-tracked files are candidates.
|
|
79
|
+
*
|
|
80
|
+
* Ignored code is by definition not part of the shipped system, and treating it as governable
|
|
81
|
+
* produces specs for scratch work, build output and local experiments. A non-git tree accepts
|
|
82
|
+
* everything and reports `isGit: false`, which is honest rather than silently wrong.
|
|
83
|
+
*/
|
|
84
|
+
function candidates(root, scan) {
|
|
85
|
+
let tracked = null;
|
|
86
|
+
try {
|
|
87
|
+
tracked = new Set(
|
|
88
|
+
// stderr is silenced: on a non-git tree git's "not a repository" complaint is an expected
|
|
89
|
+
// branch of this function, not a fault worth printing over the caller's output.
|
|
90
|
+
(0, node_child_process_1.execFileSync)('git', ['-C', root, 'ls-files', '-z'], { encoding: 'utf8', maxBuffer: 1 << 28, stdio: ['ignore', 'pipe', 'ignore'] })
|
|
91
|
+
.split('\0')
|
|
92
|
+
.filter(Boolean));
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
/* not a git repository — no filter applies */
|
|
96
|
+
}
|
|
97
|
+
const all = scan(root);
|
|
98
|
+
// @implements A-SPEC-128
|
|
99
|
+
// With git, tracked-ness already encodes the ignore rules. Without it, the SAME `.gitignore` is
|
|
100
|
+
// interpreted here — one ignore file, whoever is available to read it. A scan that ingested
|
|
101
|
+
// everything would put build output and vendored trees into the graph and into clustering.
|
|
102
|
+
const ignore = tracked ? null : (0, ignore_1.loadIgnore)(root);
|
|
103
|
+
const files = tracked
|
|
104
|
+
? all.filter((f) => tracked.has(f.sourcePath))
|
|
105
|
+
: all.filter((f) => !ignore.isIgnored(f.sourcePath));
|
|
106
|
+
// Tests come from the tracked list rather than from the scan, which excludes them by design. An
|
|
107
|
+
// untracked test is dropped for the same reason untracked code is: it is not part of the system.
|
|
108
|
+
const testFiles = (tracked ? [...tracked].filter((p) => SCANNABLE_RE.test(p)) : walkTestFiles(root))
|
|
109
|
+
.filter((p) => (0, test_files_1.isTestFile)(p) && !ignore?.isIgnored(p))
|
|
110
|
+
.sort(cmp);
|
|
111
|
+
return {
|
|
112
|
+
files, testFiles, scanned: all.length, droppedUntracked: all.length - files.length,
|
|
113
|
+
isGit: tracked !== null,
|
|
114
|
+
ignoreUnsupportedPatterns: ignore?.unsupportedPatterns() ?? [],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
const DEFAULT_MAX_FLAGGED = 50;
|
|
118
|
+
const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
119
|
+
function reverseScan(root, opts = {}) {
|
|
120
|
+
// An unreadable root must fail loudly: an empty inventory over a tree that was never read looks
|
|
121
|
+
// exactly like an empty repository, and the caller would act on "nothing to govern here".
|
|
122
|
+
const stat = fs.statSync(root);
|
|
123
|
+
if (!stat.isDirectory())
|
|
124
|
+
throw new Error(`reverse_scan: ${root} is not a directory`);
|
|
125
|
+
const scan = opts.scan ?? ((r) => new cpg_scanner_1.CpgScanner().scan(r, r));
|
|
126
|
+
const { files, testFiles, scanned, droppedUntracked, isGit, ignoreUnsupportedPatterns } = candidates(root, scan);
|
|
127
|
+
const clusters = (0, cluster_1.clusterFiles)(files);
|
|
128
|
+
const production = clusters.filter((c) => !c.isTest);
|
|
129
|
+
const testClusters = clusters.filter((c) => c.isTest);
|
|
130
|
+
// Attach existing tests to the slice each covers, by name evidence only. An unattachable test is
|
|
131
|
+
// reported, never attached on proximity — the anchor it would produce is an authorship claim.
|
|
132
|
+
const tests = (0, test_map_1.mapTestsToClusters)(testFiles, production);
|
|
133
|
+
const byKey = new Map(production.map((c) => [(0, draft_1.clusterKeyOf)(c), c]));
|
|
134
|
+
for (const m of tests.matched)
|
|
135
|
+
byKey.get(m.clusterKey)?.testFiles.push(m.file);
|
|
136
|
+
// Public-surface candidates, computed ONCE from the files this scan already holds. `reverse_draft`
|
|
137
|
+
// can draft every cluster in a single call, so deriving this per cluster would re-walk the same
|
|
138
|
+
// tree once per slice. Carried on the report but stripped from the `reverse_scan` TOOL response —
|
|
139
|
+
// it is evidence for drafting, not something a reader should pay context for on every scan.
|
|
140
|
+
const byPath = new Map(files.map((f) => [f.sourcePath, f]));
|
|
141
|
+
const surfaceByCluster = {};
|
|
142
|
+
for (const c of production) {
|
|
143
|
+
surfaceByCluster[(0, draft_1.clusterKeyOf)(c)] = (0, surface_1.surfaceOf)(c.files.map((p) => byPath.get(p)).filter((f) => f !== undefined));
|
|
144
|
+
}
|
|
145
|
+
const indicators = {};
|
|
146
|
+
const flagged = [];
|
|
147
|
+
const uncalibrated = new Set();
|
|
148
|
+
for (const f of files) {
|
|
149
|
+
if (!(0, dynamic_wiring_1.dynamicWiringSupported)(f.sourcePath)) {
|
|
150
|
+
uncalibrated.add(path.extname(f.sourcePath));
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
let code;
|
|
154
|
+
try {
|
|
155
|
+
code = fs.readFileSync(f.path, 'utf8');
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
continue; // a file that vanished between scan and read is not evidence of anything
|
|
159
|
+
}
|
|
160
|
+
const hits = (0, dynamic_wiring_1.detectDynamicWiring)(code, f.sourcePath);
|
|
161
|
+
for (const h of hits)
|
|
162
|
+
indicators[h] = (indicators[h] ?? 0) + 1;
|
|
163
|
+
if (hits.length)
|
|
164
|
+
flagged.push({ file: f.sourcePath, indicators: hits });
|
|
165
|
+
}
|
|
166
|
+
flagged.sort((a, b) => cmp(a.file, b.file));
|
|
167
|
+
const anchored = files.filter((f) => f.implementsSpecs.length > 0).length;
|
|
168
|
+
const countFiles = (cs) => cs.reduce((n, c) => n + c.files.length, 0);
|
|
169
|
+
return {
|
|
170
|
+
root,
|
|
171
|
+
isGit,
|
|
172
|
+
scanned,
|
|
173
|
+
droppedUntracked,
|
|
174
|
+
candidates: files.length,
|
|
175
|
+
anchored,
|
|
176
|
+
unanchored: files.length - anchored,
|
|
177
|
+
clusters: production,
|
|
178
|
+
testClusters,
|
|
179
|
+
surfaceByCluster,
|
|
180
|
+
coverage: {
|
|
181
|
+
structurallyResolved: countFiles(production),
|
|
182
|
+
inTestClusters: countFiles(testClusters),
|
|
183
|
+
cohesionUnmeasured: countFiles(clusters.filter((c) => c.cohesion === 'unmeasured')),
|
|
184
|
+
testsMatched: tests.matched.length,
|
|
185
|
+
testsUnmatched: tests.unmatched.length,
|
|
186
|
+
withDynamicIndicators: flagged.length,
|
|
187
|
+
indicators,
|
|
188
|
+
uncalibratedIndicatorLanguages: [...uncalibrated].sort(cmp),
|
|
189
|
+
ignoreUnsupportedPatterns,
|
|
190
|
+
},
|
|
191
|
+
needsHumanConfirmation: flagged.slice(0, opts.maxFlagged ?? DEFAULT_MAX_FLAGGED),
|
|
192
|
+
tests,
|
|
193
|
+
};
|
|
194
|
+
}
|