agent-doc-system 0.0.0-stage → 1.0.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/INSTALL_FOR_AGENTS.md +60 -0
- package/LICENSE +21 -0
- package/README.md +151 -2
- package/adapters/cache.mjs +27 -0
- package/adapters/contract-consumers.mjs +56 -0
- package/adapters/database.mjs +116 -0
- package/adapters/deploy-config.mjs +34 -0
- package/adapters/dockerfile.mjs +26 -0
- package/adapters/events.mjs +145 -0
- package/adapters/generic.mjs +86 -0
- package/adapters/github-actions.mjs +106 -0
- package/adapters/go.mjs +91 -0
- package/adapters/graphql.mjs +28 -0
- package/adapters/index.mjs +55 -0
- package/adapters/node.mjs +118 -0
- package/adapters/object-store.mjs +64 -0
- package/adapters/oidc.mjs +56 -0
- package/adapters/openapi.mjs +50 -0
- package/adapters/pnpm.mjs +42 -0
- package/adapters/protobuf.mjs +35 -0
- package/adapters/registry.mjs +27 -0
- package/adapters/resource-client.mjs +61 -0
- package/adapters/turborepo.mjs +135 -0
- package/adapters/typescript.mjs +85 -0
- package/adapters/wrangler.mjs +206 -0
- package/bin/agentdoc.mjs +478 -0
- package/bin/usage.txt +26 -0
- package/core/acceptances.mjs +69 -0
- package/core/audit.mjs +407 -0
- package/core/authority/classes.mjs +39 -0
- package/core/authority/engine.mjs +243 -0
- package/core/authority/facts.mjs +41 -0
- package/core/codes.mjs +119 -0
- package/core/compile.mjs +881 -0
- package/core/contracts.mjs +121 -0
- package/core/derive.mjs +102 -0
- package/core/descriptors.mjs +486 -0
- package/core/determinism.mjs +95 -0
- package/core/discover.mjs +147 -0
- package/core/facts.mjs +53 -0
- package/core/fsx.mjs +342 -0
- package/core/graph.mjs +260 -0
- package/core/impact.mjs +175 -0
- package/core/indexes.mjs +165 -0
- package/core/jsonschema.mjs +298 -0
- package/core/observations.mjs +129 -0
- package/core/provenance.mjs +83 -0
- package/core/query.mjs +384 -0
- package/core/relations.mjs +286 -0
- package/core/scaffold.mjs +322 -0
- package/core/secrets.mjs +92 -0
- package/core/sourcescan.mjs +88 -0
- package/core/toml.mjs +163 -0
- package/core/yaml.mjs +475 -0
- package/docs/ADAPTERS.md +172 -0
- package/docs/AUTHORITY.md +260 -0
- package/docs/CI.md +136 -0
- package/docs/COMPARISON-KODA.md +164 -0
- package/docs/EVALS.md +166 -0
- package/docs/MIGRATION.md +193 -0
- package/docs/PROVENANCE.md +133 -0
- package/docs/QUERY.md +180 -0
- package/docs/SPEC.md +339 -0
- package/docs/VERIFICATION.md +28 -0
- package/docs/ci/generic-ci.sh +28 -0
- package/docs/ci/github-actions.yml +35 -0
- package/evals/harness.mjs +426 -0
- package/evals/scenarios/fixture-a-01-change-api.json +9 -0
- package/evals/scenarios/fixture-a-02-change-db.json +9 -0
- package/evals/scenarios/fixture-a-03-enforce-constraint.json +9 -0
- package/evals/scenarios/fixture-b-01-change-proto.json +9 -0
- package/evals/scenarios/fixture-b-02-change-write-path.json +9 -0
- package/evals/scenarios/fixture-b-03-change-shared-lib.json +9 -0
- package/evals/scenarios/fixture-b-04-journey.json +9 -0
- package/evals/scenarios/fixture-c-01-mixed-runtime-edge.json +19 -0
- package/evals/scenarios/fixture-c-02-stale-doc-contradiction.json +9 -0
- package/evals/scenarios/fixture-c-03-shared-vocabulary.json +9 -0
- package/evals/scenarios/fixture-c-04-contract-change.json +9 -0
- package/evals/scenarios/koda-01-modify-api-consumer.json +26 -0
- package/evals/scenarios/koda-02-change-db-schema.json +20 -0
- package/evals/scenarios/koda-03-change-auth.json +21 -0
- package/evals/scenarios/koda-04-add-event-producer.json +19 -0
- package/evals/scenarios/koda-05-modify-worker-binding.json +25 -0
- package/evals/scenarios/koda-06-change-cross-component-endpoint.json +22 -0
- package/evals/scenarios/koda-07-change-shared-package.json +16 -0
- package/evals/scenarios/koda-08-change-runtime-cron.json +23 -0
- package/evals/scenarios/koda-09-debug-production-mismatch.json +26 -0
- package/evals/thresholds.json +11 -0
- package/package.json +46 -4
- package/schemas/api.schema.json +84 -0
- package/schemas/common.schema.json +107 -0
- package/schemas/component.schema.json +40 -0
- package/schemas/config.schema.json +685 -0
- package/schemas/domain.schema.json +14 -0
- package/schemas/graph.schema.json +1084 -0
- package/schemas/journeys.schema.json +47 -0
- package/schemas/observation.schema.json +143 -0
- package/schemas/resource.schema.json +28 -0
- package/schemas/system.schema.json +26 -0
- package/skills/agent-doc-system/SKILL.md +113 -0
- package/skills/agent-doc-system/references/authority-and-conflicts.md +70 -0
- package/skills/agent-doc-system/references/cli.md +89 -0
- package/skills/agent-doc-system/references/create-migrate.md +101 -0
- package/skills/agent-doc-system/references/maintenance.md +52 -0
- package/templates/ADR.md +26 -0
- package/templates/ARCHITECTURE.md +19 -0
- package/templates/CONSTRAINTS.md +17 -0
- package/templates/PRODUCT.md +17 -0
- package/templates/api.yaml +10 -0
- package/templates/ci.yml +31 -0
- package/templates/component.yaml +14 -0
- package/templates/domain.yaml +6 -0
- package/templates/journey.yaml +7 -0
- package/templates/observation.yaml +19 -0
- package/templates/resource.yaml +8 -0
- package/templates/system.yaml +10 -0
- package/templates/warning-acceptance.yaml +14 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// CREATE-mode scaffolding.
|
|
3
|
+
//
|
|
4
|
+
// Generates a descriptor skeleton from what the repository can actually prove.
|
|
5
|
+
// The generation rules are deliberately conservative:
|
|
6
|
+
// - a component descriptor is written only for a discovered unit with a name
|
|
7
|
+
// the unit itself supplies (package name, module path, or directory name);
|
|
8
|
+
// - a description is a template sentence naming the artifact evidence, never
|
|
9
|
+
// an invented product claim;
|
|
10
|
+
// - a placement that cannot be inferred is left out, and the compiler will
|
|
11
|
+
// report it as a question rather than the scaffolder guessing.
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { AgentDocError, CODES } from "./codes.mjs";
|
|
16
|
+
import { Repo } from "./fsx.mjs";
|
|
17
|
+
import { classifyContractFile } from "./contracts.mjs";
|
|
18
|
+
|
|
19
|
+
const TEMPLATES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "templates");
|
|
20
|
+
|
|
21
|
+
function yamlStr(s) {
|
|
22
|
+
return '"' + String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function kebab(s) {
|
|
26
|
+
return String(s)
|
|
27
|
+
.replace(/^@/, "")
|
|
28
|
+
.replace(/[_\s]+/g, "-")
|
|
29
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
|
30
|
+
.replace(/[^A-Za-z0-9-]+/g, "-")
|
|
31
|
+
.replace(/-+/g, "-")
|
|
32
|
+
.replace(/^-+|-+$/g, "")
|
|
33
|
+
.toLowerCase()
|
|
34
|
+
.slice(0, 63) || "component";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function sentence(s) {
|
|
38
|
+
const t = String(s).replace(/\s+/g, " ").trim();
|
|
39
|
+
if (!t) return "TODO: describe this in one present-tense sentence.";
|
|
40
|
+
return /[.!?]$/.test(t) ? t : t + ".";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function installTemplates(repo, cfg, { write, root, force = false, eligible = null } = {}) {
|
|
44
|
+
const written = [];
|
|
45
|
+
const skipped = [];
|
|
46
|
+
const targetRoot = root || (repo ? repo.root : process.cwd());
|
|
47
|
+
const r = repo || new Repo(targetRoot, { noGit: false });
|
|
48
|
+
|
|
49
|
+
const put = (rel, content) => {
|
|
50
|
+
const abs = path.join(targetRoot, rel);
|
|
51
|
+
if (fs.existsSync(abs) && !force) { skipped.push(rel); return; }
|
|
52
|
+
if (!write) { written.push(rel + " (dry run)"); return; }
|
|
53
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
54
|
+
fs.writeFileSync(abs, content.endsWith("\n") ? content : content + "\n");
|
|
55
|
+
written.push(rel);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// ── configuration ───────────────────────────────────────────────────────
|
|
59
|
+
const config = buildConfig(scanLayout(r));
|
|
60
|
+
put("agentdoc/agentdoc.config.yaml", config);
|
|
61
|
+
put("agentdoc/journeys.yaml", "schemaVersion: agentdoc.dev/journeys/v1\njourneys: []\n");
|
|
62
|
+
for (const f of ["domains.yaml", "systems.yaml", "apis.yaml", "resources.yaml"]) {
|
|
63
|
+
put("agentdoc/" + f, "");
|
|
64
|
+
}
|
|
65
|
+
put("agentdoc/observations/README.md", OBSERVATIONS_README);
|
|
66
|
+
|
|
67
|
+
// ── documentation hierarchy ──────────────────────────────────────────────
|
|
68
|
+
for (const f of ["PRODUCT.md", "ARCHITECTURE.md", "CONSTRAINTS.md"]) {
|
|
69
|
+
const tpl = fs.readFileSync(path.join(TEMPLATES_DIR, f), "utf8");
|
|
70
|
+
put("docs/" + f, tpl);
|
|
71
|
+
}
|
|
72
|
+
put("docs/adr/README.md", fs.readFileSync(path.join(TEMPLATES_DIR, "ADR.md"), "utf8"));
|
|
73
|
+
put(".github/workflows/agentdoc.yml", fs.readFileSync(path.join(TEMPLATES_DIR, "ci.yml"), "utf8"));
|
|
74
|
+
|
|
75
|
+
// ── per-unit component descriptors ──────────────────────────────────────
|
|
76
|
+
if (cfg) {
|
|
77
|
+
// When the caller ran the compiler's discovery first, write for the units
|
|
78
|
+
// the compiler itself counts as eligible — adapter-discovered roots
|
|
79
|
+
// (go.work members, pnpm workspace packages, supplementalRoots) may have
|
|
80
|
+
// no marker file the layout scan would find on its own.
|
|
81
|
+
const units = discoverUnitNames(r, cfg, eligible);
|
|
82
|
+
for (const u of units) {
|
|
83
|
+
put(u.root + "/" + cfg.discovery.componentDescriptorName, componentDescriptor(u));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return { written, skipped, note: write ? "files written" : "dry run: pass --write to apply" };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Layout inference for CREATE mode: which directories look like unit roots.
|
|
90
|
+
// Uses only file-presence heuristics that any repository can answer.
|
|
91
|
+
function scanLayout(repo) {
|
|
92
|
+
const candidates = new Map();
|
|
93
|
+
const note = (root, kind) => {
|
|
94
|
+
if (!candidates.has(root)) candidates.set(root, { root, kinds: new Set(), evidence: [] });
|
|
95
|
+
candidates.get(root).kinds.add(kind);
|
|
96
|
+
candidates.get(root).evidence.push(kind);
|
|
97
|
+
};
|
|
98
|
+
const skip = new Set(["node_modules", ".git", "dist", "build", "vendor", "target", "agentdoc", "docs", "scripts", ".github", "test", "tests", "examples", "example"]);
|
|
99
|
+
const walk = (dir, depth) => {
|
|
100
|
+
if (depth > 3) return;
|
|
101
|
+
let entries;
|
|
102
|
+
try { entries = fs.readdirSync(path.join(repo.root, dir), { withFileTypes: true }); } catch { return; }
|
|
103
|
+
for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
104
|
+
if (e.isSymbolicLink() || skip.has(e.name)) continue;
|
|
105
|
+
const rel = dir ? dir + "/" + e.name : e.name;
|
|
106
|
+
if (e.isDirectory()) {
|
|
107
|
+
if (e.name.startsWith(".")) continue;
|
|
108
|
+
walk(rel, depth + 1);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
// Parent directory of the manifest. Parenthesised deliberately: without
|
|
112
|
+
// the brackets, `-"/" + e.name` binds first and slice(0, NaN) silently
|
|
113
|
+
// yields the empty string, which then crashes the layout analysis.
|
|
114
|
+
const parent = () => rel.slice(0, -("/" + e.name).length);
|
|
115
|
+
if (e.name === "package.json") note(parent(), "node");
|
|
116
|
+
if (e.name === "go.mod") note(parent(), "go");
|
|
117
|
+
if (e.name === "Dockerfile") note(parent(), "docker");
|
|
118
|
+
if (/^wrangler\.(toml|json|jsonc)$/.test(e.name)) note(parent(), "platform");
|
|
119
|
+
if (e.name === "go.mod") note(rel.slice(0, -"/go.mod".length), "go");
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
walk("", 0);
|
|
123
|
+
return [...candidates.values()].sort((a, b) => (a.root < b.root ? -1 : 1));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function discoverUnitNames(repo, cfg, eligible = null) {
|
|
127
|
+
const out = [];
|
|
128
|
+
const nameFor = (root) => {
|
|
129
|
+
const pkg = root + "/package.json";
|
|
130
|
+
if (repo.exists(pkg)) {
|
|
131
|
+
try {
|
|
132
|
+
const j = repo.readJson(pkg);
|
|
133
|
+
if (typeof j.name === "string" && j.name) return kebab(j.name);
|
|
134
|
+
} catch { /* fall through */ }
|
|
135
|
+
}
|
|
136
|
+
const gomod = root + "/go.mod";
|
|
137
|
+
if (repo.exists(gomod)) {
|
|
138
|
+
const m = /^module\s+(\S+)/m.exec(repo.readText(gomod));
|
|
139
|
+
if (m) return kebab(m[1].split("/").pop());
|
|
140
|
+
}
|
|
141
|
+
return kebab(path.basename(root));
|
|
142
|
+
};
|
|
143
|
+
const seen = new Set();
|
|
144
|
+
const seenRoots = new Set();
|
|
145
|
+
// Union, not replacement: compiler-eligible units (adapter roots,
|
|
146
|
+
// supplementalRoots) cover what markers cannot see, while the layout scan
|
|
147
|
+
// still covers marker-bearing dirs that no adapter claims.
|
|
148
|
+
const units = [
|
|
149
|
+
...(eligible || []).map((u) => ({
|
|
150
|
+
root: u.root,
|
|
151
|
+
kinds: (u.markers || []).map((m) => m.adapter).sort(),
|
|
152
|
+
deployable: Boolean(u.deployable),
|
|
153
|
+
importable: Boolean(u.importable),
|
|
154
|
+
})),
|
|
155
|
+
...scanLayout(repo).map((u) => ({ root: u.root, kinds: [...u.kinds].sort(), deployable: false, importable: false })),
|
|
156
|
+
];
|
|
157
|
+
for (const u of units) {
|
|
158
|
+
if (u.root === "." || !u.root || seenRoots.has(u.root)) continue;
|
|
159
|
+
seenRoots.add(u.root);
|
|
160
|
+
const name = nameFor(u.root);
|
|
161
|
+
if (seen.has(name)) continue;
|
|
162
|
+
seen.add(name);
|
|
163
|
+
out.push({ ...u, name });
|
|
164
|
+
}
|
|
165
|
+
void cfg;
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function componentDescriptor(u) {
|
|
170
|
+
const deployable = u.deployable || u.kinds.some((k) => k === "docker" || k === "platform");
|
|
171
|
+
const importable = u.importable || u.kinds.some((k) => k === "node" || k === "go");
|
|
172
|
+
// "library" and "service" both have to be corroborated by artifact evidence
|
|
173
|
+
// (AGENTDOC_ARTIFACT_TYPE); a unit the compiler only knows from configuration
|
|
174
|
+
// shows neither, so claiming a concrete type would manufacture an error.
|
|
175
|
+
const type = deployable ? "service" : importable ? "library" : "other";
|
|
176
|
+
return [
|
|
177
|
+
"apiVersion: agentdoc.dev/v1",
|
|
178
|
+
"kind: Component",
|
|
179
|
+
"metadata:",
|
|
180
|
+
" name: " + u.name,
|
|
181
|
+
" description: " + yamlStr(sentence("TODO: " + u.name + " (" + (u.kinds.length ? u.kinds.join("/") : "configured") + " at " + u.root + ") — state its single current responsibility")),
|
|
182
|
+
"spec:",
|
|
183
|
+
" type: " + type,
|
|
184
|
+
" # Choose exactly one of the three lines below. Uncomment one.",
|
|
185
|
+
" # system: <system-name>",
|
|
186
|
+
" # domain: <domain-name>",
|
|
187
|
+
" # placementRationale: " + yamlStr(sentence("TODO: explain why this component belongs to no system or domain")),
|
|
188
|
+
"",
|
|
189
|
+
].join("\n");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Which adapter a discovered manifest marker implies. Project-agnostic: this
|
|
193
|
+
// maps a technology to its extractor, never a folder name to a component.
|
|
194
|
+
const ADAPTER_FOR_MARKER = {
|
|
195
|
+
node: "node",
|
|
196
|
+
go: "go",
|
|
197
|
+
docker: "dockerfile",
|
|
198
|
+
platform: "wrangler",
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
function buildConfig(layout) {
|
|
202
|
+
const roots = layout.map((l) => l.root);
|
|
203
|
+
const groups = new Map();
|
|
204
|
+
for (const r of roots) {
|
|
205
|
+
const head = r.split("/")[0];
|
|
206
|
+
if (!groups.has(head)) groups.set(head, []);
|
|
207
|
+
groups.get(head).push(r);
|
|
208
|
+
}
|
|
209
|
+
// A manifest at the repository root yields an empty head; emitting
|
|
210
|
+
// "/agentdoc.yaml" would be an absolute glob, which the loader rejects.
|
|
211
|
+
const componentGlobs = [...groups.entries()]
|
|
212
|
+
.filter(([head]) => head.length > 0)
|
|
213
|
+
.sort((a, b) => (a[0] < b[0] ? -1 : 1))
|
|
214
|
+
.map(([head, list]) => (list.length === 1 && list[0] === head ? head + "/agentdoc.yaml" : head + "/**/agentdoc.yaml"));
|
|
215
|
+
if (!componentGlobs.length) componentGlobs.push("*/**/agentdoc.yaml");
|
|
216
|
+
|
|
217
|
+
const adapters = new Set(["generic"]);
|
|
218
|
+
for (const l of layout) {
|
|
219
|
+
for (const k of l.kinds) {
|
|
220
|
+
if (ADAPTER_FOR_MARKER[k]) adapters.add(ADAPTER_FOR_MARKER[k]);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
adapters.add("database");
|
|
224
|
+
adapters.add("openapi");
|
|
225
|
+
adapters.add("events");
|
|
226
|
+
adapters.add("github-actions");
|
|
227
|
+
|
|
228
|
+
// Candidate contract roots, filtered to those that actually exist. These are
|
|
229
|
+
// only suggestions: the engine has no opinion about a folder name.
|
|
230
|
+
const contractRoots = ["contracts", "api", "specs", "proto", "openapi", "schemas"].filter((r) => groups.has(r) || r === "");
|
|
231
|
+
return [
|
|
232
|
+
"# agentdoc configuration — the only project-specific file in the system.",
|
|
233
|
+
"# Everything the engine does is derived from this plus the repository.",
|
|
234
|
+
"apiVersion: agentdoc.dev/config/v1",
|
|
235
|
+
"# namespace prefixes every entity ref (component:<namespace>/<name>).",
|
|
236
|
+
"# 'default' is a placeholder — pick the name this repository calls itself.",
|
|
237
|
+
"namespace: default",
|
|
238
|
+
"",
|
|
239
|
+
"discovery:",
|
|
240
|
+
" componentDescriptorName: agentdoc.yaml",
|
|
241
|
+
" componentDescriptors:",
|
|
242
|
+
// Globs are quoted: an unquoted leading "*" is an alias in strict YAML,
|
|
243
|
+
// and a configuration the compiler cannot parse is worse than none.
|
|
244
|
+
...componentGlobs.map((g) => " - " + yamlStr(g)),
|
|
245
|
+
" centralDescriptors:",
|
|
246
|
+
" - agentdoc/domains.yaml",
|
|
247
|
+
" - agentdoc/systems.yaml",
|
|
248
|
+
" - agentdoc/apis.yaml",
|
|
249
|
+
" - agentdoc/resources.yaml",
|
|
250
|
+
" journeyDefinitions: agentdoc/journeys.yaml",
|
|
251
|
+
...(contractRoots.filter((r) => groups.has(r)).length
|
|
252
|
+
? [" contractRoots:", ...contractRoots.filter((r) => groups.has(r)).map((r) => " - " + r)]
|
|
253
|
+
: [" # contractRoots: [] # uncomment once you have a canonical contract directory"]),
|
|
254
|
+
" # eventPatternPrefixes:",
|
|
255
|
+
" # - acme.",
|
|
256
|
+
"",
|
|
257
|
+
"adapters:",
|
|
258
|
+
...[...adapters].sort().map((a) => " - " + a),
|
|
259
|
+
"",
|
|
260
|
+
"authority:",
|
|
261
|
+
" # No default global precedence exists. Every fact kind that can disagree",
|
|
262
|
+
" # across evidence classes needs an explicit rule, or it stays unresolved.",
|
|
263
|
+
" #",
|
|
264
|
+
" # The three rules below are COMMENTED OUT ON PURPOSE. An authority rule is a",
|
|
265
|
+
" # governance decision about which class of evidence wins, and a scaffolder is",
|
|
266
|
+
" # not entitled to make it for you. Uncomment the ones you can justify, and",
|
|
267
|
+
" # write the rationale in your own words:",
|
|
268
|
+
" #",
|
|
269
|
+
" # rules:",
|
|
270
|
+
" # - id: deployed-schedule-is-runtime-truth",
|
|
271
|
+
" # keyPattern: ' schedule\\\\.(cron|enabled|target)$'",
|
|
272
|
+
" # elect: [OBSERVED_RUNTIME]",
|
|
273
|
+
' # rationale: "Why this class is authoritative for this key."',
|
|
274
|
+
' # reviewWhen: "What has to change for this rule to be re-examined."',
|
|
275
|
+
" rules: []",
|
|
276
|
+
"",
|
|
277
|
+
"reviewedOverrides: []",
|
|
278
|
+
"warningAcceptances: []",
|
|
279
|
+
"",
|
|
280
|
+
"output:",
|
|
281
|
+
" graph: .agentdoc/graph.json",
|
|
282
|
+
" observations: agentdoc/observations",
|
|
283
|
+
"",
|
|
284
|
+
"docs:",
|
|
285
|
+
" product: docs/PRODUCT.md",
|
|
286
|
+
" architecture: docs/ARCHITECTURE.md",
|
|
287
|
+
" constraints: docs/CONSTRAINTS.md",
|
|
288
|
+
" adrDir: docs/adr",
|
|
289
|
+
"",
|
|
290
|
+
].join("\n");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const OBSERVATIONS_README = `# Runtime observations
|
|
294
|
+
|
|
295
|
+
An ObservationSet records what an authoritative external system actually said,
|
|
296
|
+
at a known time, with a durable reference to the raw evidence.
|
|
297
|
+
|
|
298
|
+
## Rules
|
|
299
|
+
|
|
300
|
+
1. Never put a secret, token or credentialed URL in an observation, an evidence
|
|
301
|
+
bundle, or anywhere else in this directory. Run \`agentdoc validate\`; it
|
|
302
|
+
fails on secret-shaped input.
|
|
303
|
+
2. Every fact needs a \`subject\` (an entity ref that exists) and a \`key\`
|
|
304
|
+
(see "Fact keys" in the AgentDoc specification,
|
|
305
|
+
docs/SPEC.md of https://github.com/lbframe/agent-doc-system). Two
|
|
306
|
+
observation sets must not use the same key on the same subject.
|
|
307
|
+
3. \`evidenceBundle\` must be a committed, repository-relative file. A temp
|
|
308
|
+
path, a console URL with a session token, or a screenshot filename is not
|
|
309
|
+
evidence.
|
|
310
|
+
4. \`maxAgeDays\` is a promise. When the observation is older than that, every
|
|
311
|
+
gate fails closed and says so. Re-observe, or restate the fact as authored
|
|
312
|
+
intent if the divergence is intended.
|
|
313
|
+
|
|
314
|
+
## Shape
|
|
315
|
+
|
|
316
|
+
See the \`observation.yaml\` template shipped with the agentdoc CLI
|
|
317
|
+
(templates/observation.yaml in the installed package).
|
|
318
|
+
`;
|
|
319
|
+
|
|
320
|
+
export function templatePaths() {
|
|
321
|
+
return { dir: TEMPLATES_DIR };
|
|
322
|
+
}
|
package/core/secrets.mjs
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Secret material must never reach the compiled graph, a report, a log line,
|
|
2
|
+
// or a prompt. Two layers:
|
|
3
|
+
//
|
|
4
|
+
// 1. Source scanning — every authored/observed document is scanned before it
|
|
5
|
+
// can influence any output.
|
|
6
|
+
// 2. Output scanning — the serialized graph, every report and every query
|
|
7
|
+
// payload is scanned again, because derivations and extractor heuristics can
|
|
8
|
+
// synthesize a secret-shaped string that no source scan would have caught.
|
|
9
|
+
import { AgentDocError, CODES } from "./codes.mjs";
|
|
10
|
+
|
|
11
|
+
const PATTERNS = [
|
|
12
|
+
{ re: /-----BEGIN (?:[A-Z0-9 ]+ )?PRIVATE KEY-----/, why: "private key material" },
|
|
13
|
+
{ re: /\bAKIA[0-9A-Z]{16}\b/, why: "AWS-style access key id" },
|
|
14
|
+
{ re: /\bASIA[0-9A-Z]{16}\b/, why: "AWS-style temporary access key id" },
|
|
15
|
+
{ re: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/, why: "GitHub token" },
|
|
16
|
+
{ re: /\bgithub_pat_[A-Za-z0-9_]{20,}\b/, why: "GitHub fine-grained token" },
|
|
17
|
+
{ re: /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/, why: "Slack token" },
|
|
18
|
+
{ re: /\bsk-[A-Za-z0-9]{20,}\b/, why: "provider secret key" },
|
|
19
|
+
{ re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/, why: "JWT-shaped token" },
|
|
20
|
+
{ re: /\b[a-z][a-z0-9+.-]*:\/\/[^\s/@]+:[^\s/@]+@/, why: "URL with embedded credentials" },
|
|
21
|
+
{
|
|
22
|
+
re: /\b(?:secret|password|passwd|passphrase|token|api[_-]?key|private[_-]?key|client[_-]?secret|auth[_-]?token)\b\s*[:=]\s*["']?[^\s"',;]{8,}/i,
|
|
23
|
+
why: "credential assignment",
|
|
24
|
+
},
|
|
25
|
+
{ re: /\bBearer\s+[A-Za-z0-9._~+/-]{20,}=*/, why: "bearer token" },
|
|
26
|
+
{ re: /\b(?:curl|wget)\b[^\n]*\s-[a-z]*u\s+\S+:\S+/i, why: "inline credentials on a command line" },
|
|
27
|
+
{ re: /\bAuthorization\s*[:=]\s*["']?[^\s"']{8,}/i, why: "authorization header value" },
|
|
28
|
+
{ re: /\baws_secret_access_key\b\s+\S{16,}/i, why: "AWS secret access key" },
|
|
29
|
+
{ re: /\b(?:api[_-]?key|apikey|access[_-]?token|auth[_-]?token)\b\s+["']?[A-Za-z0-9_\-]{16,}/i, why: "credential passed as an argument" },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
function scanString(value, where) {
|
|
33
|
+
// Values this system already redacted are not secrets. Removing the marker
|
|
34
|
+
// before matching stops the output scanner from refusing to emit its own
|
|
35
|
+
// redaction, which would make a redacted command unusable.
|
|
36
|
+
const v = String(value).replace(/\[REDACTED[^\]]*\]/g, "");
|
|
37
|
+
for (const { re, why } of PATTERNS) {
|
|
38
|
+
if (re.test(v)) {
|
|
39
|
+
throw new AgentDocError(CODES.SECRET, "secret-like value (" + why + ") at " + where + ": agentdoc sources and outputs must never contain credentials", {});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Recursive scan of an in-memory document.
|
|
45
|
+
export function scanForSecrets(node, path, pointer = "") {
|
|
46
|
+
if (node == null) return;
|
|
47
|
+
if (typeof node === "string") return scanString(node, pointer || "/");
|
|
48
|
+
if (typeof node !== "object") return;
|
|
49
|
+
if (Array.isArray(node)) {
|
|
50
|
+
node.forEach((item, i) => scanForSecrets(item, path, pointer + "/" + i));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
for (const [k, v] of Object.entries(node)) {
|
|
54
|
+
scanForSecrets(v, path, pointer + "/" + k.replace(/~/g, "~0").replace(/\//g, "~1"));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Scan serialized text (a graph, a report, a query payload). Throws on the
|
|
59
|
+
// first hit; the caller decides whether that is fatal.
|
|
60
|
+
export function assertNoSecretsInText(text, what) {
|
|
61
|
+
const scrubbed = String(text).replace(/\[REDACTED[^\]]*\]/g, "");
|
|
62
|
+
for (const { re, why } of PATTERNS) {
|
|
63
|
+
const m = re.exec(scrubbed);
|
|
64
|
+
if (m) {
|
|
65
|
+
throw new AgentDocError(
|
|
66
|
+
CODES.SECRET,
|
|
67
|
+
"refusing to emit " + what + ": secret-like value (" + why + ") near offset " + m.index
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Verification commands are a routing index, not a credential transport. Keep
|
|
74
|
+
// the runnable shape; strip any inline assignment or DSN password.
|
|
75
|
+
const SECRET_NAME = /(?:SECRET|TOKEN|PASSWORD|PASSWD|PASSPHRASE|API_?KEY|PRIVATE_?KEY|CREDENTIAL|ACCESS_?KEY)/i;
|
|
76
|
+
|
|
77
|
+
export function redactCommand(command) {
|
|
78
|
+
return String(command)
|
|
79
|
+
// Assignment form. The name is matched structurally and then tested, rather
|
|
80
|
+
// than by a single backtracking pattern: "API_KEY" must be recognised as
|
|
81
|
+
// an API key, which a greedy name group would swallow whole.
|
|
82
|
+
.replace(/\b([A-Za-z_][A-Za-z0-9_]*)=([^\s;"'`]+)/g, (m, name, value) =>
|
|
83
|
+
SECRET_NAME.test(name) ? name + "=[REDACTED]" : m)
|
|
84
|
+
.replace(/\b((?:postgres|postgresql|mysql|mongodb(?:\+srv)?|redis|amqp|https?):\/\/)[^:@/\s]+:[^@/\s]+@/gi,
|
|
85
|
+
"$1[REDACTED]@")
|
|
86
|
+
.replace(/\b(Bearer\s+)[A-Za-z0-9._~+/-]{12,}=*/g, "$1[REDACTED]")
|
|
87
|
+
// `curl -u user:pass` and `<credential-name> <value>` as separate arguments.
|
|
88
|
+
.replace(/(\s-[a-zA-Z]*u\s+)(\S+):(\S+)/g, "$1$2:[REDACTED]")
|
|
89
|
+
.replace(/\b(aws_secret_access_key|aws_access_key_id)(\s+)([A-Za-z0-9/+=]{12,})/gi, "$1$2[REDACTED]")
|
|
90
|
+
.replace(/\b(Authorization)(\s*[:=]\s*)(\S{8,})/gi, "$1$2[REDACTED]")
|
|
91
|
+
.replace(/\b(api[_-]?key|apikey|access[_-]?token|auth[_-]?token)(\s+)(["']?)([A-Za-z0-9_-]{16,})/gi, "$1$2$3[REDACTED]");
|
|
92
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Source scanning primitives shared by adapters.
|
|
2
|
+
//
|
|
3
|
+
// The extraction of quoted module specifiers is language-neutral: a bare
|
|
4
|
+
// identifier in quotes that is not a relative path is a candidate dependency
|
|
5
|
+
// reference in practically every language. Deciding whether that reference
|
|
6
|
+
// resolves to a sibling unit is the adapter's job, because only the adapter
|
|
7
|
+
// knows the namespace convention.
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
|
|
11
|
+
const SPECIFIER_RE = /["'`]([A-Za-z@][A-Za-z0-9@._/+-]*(?:\.[A-Za-z0-9@._/+-]+)*(?:\/[A-Za-z0-9@._+*-]+)*)["'`]/g;
|
|
12
|
+
|
|
13
|
+
export function importSpecifiers(text) {
|
|
14
|
+
const out = new Set();
|
|
15
|
+
for (const m of text.matchAll(SPECIFIER_RE)) {
|
|
16
|
+
const s = m[1];
|
|
17
|
+
if (s.startsWith(".") || s.startsWith("/")) continue;
|
|
18
|
+
if (s.length < 2) continue;
|
|
19
|
+
out.add(s);
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function sourceFiles(repo, root, { test = false, extensions } = {}) {
|
|
25
|
+
const TEST_PATH = /(^|\/)(tests?|__tests__|spec)\//;
|
|
26
|
+
const TEST_FILE = /\.(test|spec)\./;
|
|
27
|
+
const DEFAULT_EXT = /\.(ts|tsx|js|jsx|mts|cts|mjs|cjs|go|py|rb|java|kt|rs|ex|exs|cs|php|swift|scala|sh)$/;
|
|
28
|
+
const re = extensions || DEFAULT_EXT;
|
|
29
|
+
const out = [];
|
|
30
|
+
for (const f of repo.walk(root)) {
|
|
31
|
+
if (!re.test(f)) continue;
|
|
32
|
+
const isTest = TEST_PATH.test(f) || TEST_FILE.test(f) || /_test\.[a-z]+$/.test(f);
|
|
33
|
+
if (isTest !== test) continue;
|
|
34
|
+
out.push(f);
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Directory candidates that hold schema migrations or an ORM schema. Used to
|
|
40
|
+
// find "this component owns a database" evidence without assuming a framework.
|
|
41
|
+
export const MIGRATION_DIR_CANDIDATES = [
|
|
42
|
+
"migrations", "db/migrations", "database/migrations", "internal/db/migrations",
|
|
43
|
+
"internal/migrations", "src/db/migrations", "alembic/versions", "liquibase",
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
// Any directory named "migrations" inside a unit, at bounded depth. Framework
|
|
47
|
+
// and language conventions move; the name of a migration directory has been
|
|
48
|
+
// stable for far longer than its location.
|
|
49
|
+
export function findMigrationDirs(repo, root, maxDepth = 5) {
|
|
50
|
+
const out = [];
|
|
51
|
+
// Absolute paths throughout: `root` is repository-relative, and resolving it
|
|
52
|
+
// against the process working directory would make the result depend on
|
|
53
|
+
// where the compiler was invoked from.
|
|
54
|
+
const base = repo.abs(root);
|
|
55
|
+
const walk = (dirAbs, depth) => {
|
|
56
|
+
if (depth > maxDepth) return;
|
|
57
|
+
let entries;
|
|
58
|
+
try {
|
|
59
|
+
entries = fs.readdirSync(dirAbs, { withFileTypes: true });
|
|
60
|
+
} catch {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
64
|
+
if (!e.isDirectory() || e.isSymbolicLink()) continue;
|
|
65
|
+
if (["node_modules", ".git", "vendor", "target", "dist", "build", ".next", "__pycache__"].includes(e.name)) continue;
|
|
66
|
+
const abs = path.join(dirAbs, e.name);
|
|
67
|
+
if (e.name === "migrations" || e.name === "migration") {
|
|
68
|
+
out.push(path.relative(repo.root, abs).split(path.sep).join("/"));
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
walk(abs, depth + 1);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
walk(base, 0);
|
|
75
|
+
return out.sort();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function firstExistingFile(repo, root, names) {
|
|
79
|
+
for (const n of names) {
|
|
80
|
+
if (repo.exists(root + "/" + n)) return root + "/" + n;
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function relativeTo(root, p) {
|
|
86
|
+
const r = path.posix.relative(root, p);
|
|
87
|
+
return r === "" ? "." : r;
|
|
88
|
+
}
|
package/core/toml.mjs
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// Minimal TOML reader for platform deployment manifests.
|
|
2
|
+
//
|
|
3
|
+
// Only what a deployment manifest needs: tables, arrays of tables, key/value
|
|
4
|
+
// pairs, strings, integers, booleans, inline arrays and inline tables. Anything
|
|
5
|
+
// richer is rejected rather than guessed at, because a mis-parsed manifest
|
|
6
|
+
// would produce confident nonsense about deployed state.
|
|
7
|
+
import { AgentDocError, CODES } from "./codes.mjs";
|
|
8
|
+
|
|
9
|
+
export function parse(src, path = "") {
|
|
10
|
+
const root = {};
|
|
11
|
+
let cur = root;
|
|
12
|
+
const lines = String(src).split("\n");
|
|
13
|
+
for (let i = 0; i < lines.length; i++) {
|
|
14
|
+
const line = stripComment(lines[i]).trim();
|
|
15
|
+
if (!line) continue;
|
|
16
|
+
const table = /^\[\[?([^\]]+)\]\]?$/.exec(line);
|
|
17
|
+
if (table) {
|
|
18
|
+
const isArray = line.startsWith("[[");
|
|
19
|
+
const keys = splitKeyPath(table[1]);
|
|
20
|
+
cur = root;
|
|
21
|
+
for (let k = 0; k < keys.length; k++) {
|
|
22
|
+
const key = keys[k];
|
|
23
|
+
const last = k === keys.length - 1;
|
|
24
|
+
if (last && isArray) {
|
|
25
|
+
if (!Array.isArray(cur[key])) cur[key] = [];
|
|
26
|
+
const obj = {};
|
|
27
|
+
cur[key].push(obj);
|
|
28
|
+
cur = obj;
|
|
29
|
+
} else {
|
|
30
|
+
if (cur[key] === undefined) cur[key] = {};
|
|
31
|
+
cur = cur[key];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const eq = findEquals(line);
|
|
37
|
+
if (eq < 0) {
|
|
38
|
+
throw new AgentDocError(CODES.YAML_PARSE, "unparseable TOML line " + (i + 1) + (path ? " in " + path : ""), { path, line: i + 1 });
|
|
39
|
+
}
|
|
40
|
+
const key = unquoteKey(line.slice(0, eq).trim());
|
|
41
|
+
let valueText = line.slice(eq + 1).trim();
|
|
42
|
+
// Multi-line arrays.
|
|
43
|
+
while (unbalanced(valueText) && i + 1 < lines.length) {
|
|
44
|
+
i++;
|
|
45
|
+
valueText += " " + stripComment(lines[i]).trim();
|
|
46
|
+
}
|
|
47
|
+
const keys = splitKeyPath(key);
|
|
48
|
+
let target = cur;
|
|
49
|
+
for (let k = 0; k < keys.length - 1; k++) {
|
|
50
|
+
if (target[keys[k]] === undefined) target[keys[k]] = {};
|
|
51
|
+
target = target[keys[k]];
|
|
52
|
+
}
|
|
53
|
+
target[keys[keys.length - 1]] = value(valueText, path, i + 1);
|
|
54
|
+
}
|
|
55
|
+
return root;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function unbalanced(s) {
|
|
59
|
+
let b = 0;
|
|
60
|
+
let q = null;
|
|
61
|
+
for (let i = 0; i < s.length; i++) {
|
|
62
|
+
const c = s[i];
|
|
63
|
+
if (q) { if (c === "\\") { i++; continue; } if (c === q) q = null; continue; }
|
|
64
|
+
if (c === '"' || c === "'") { q = c; continue; }
|
|
65
|
+
if (c === "[" || c === "{") b++;
|
|
66
|
+
if (c === "]" || c === "}") b--;
|
|
67
|
+
}
|
|
68
|
+
return b > 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function stripComment(s) {
|
|
72
|
+
let q = null;
|
|
73
|
+
for (let i = 0; i < s.length; i++) {
|
|
74
|
+
const c = s[i];
|
|
75
|
+
if (q) { if (c === "\\" && q === '"') { i++; continue; } if (c === q) q = null; continue; }
|
|
76
|
+
if (c === '"' || c === "'") { q = c; continue; }
|
|
77
|
+
if (c === "#") return s.slice(0, i);
|
|
78
|
+
}
|
|
79
|
+
return s;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function findEquals(line) {
|
|
83
|
+
let q = null;
|
|
84
|
+
for (let i = 0; i < line.length; i++) {
|
|
85
|
+
const c = line[i];
|
|
86
|
+
if (q) { if (c === "\\" && q === '"') { i++; continue; } if (c === q) q = null; continue; }
|
|
87
|
+
if (c === '"' || c === "'") { q = c; continue; }
|
|
88
|
+
if (c === "=") return i;
|
|
89
|
+
}
|
|
90
|
+
return -1;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function splitKeyPath(s) {
|
|
94
|
+
return s.split(".").map((k) => unquoteKey(k.trim()));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function unquoteKey(k) {
|
|
98
|
+
if ((k.startsWith('"') && k.endsWith('"')) || (k.startsWith("'") && k.endsWith("'"))) return k.slice(1, -1);
|
|
99
|
+
return k;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function value(t, path, line) {
|
|
103
|
+
if (t === "true") return true;
|
|
104
|
+
if (t === "false") return false;
|
|
105
|
+
if (/^".*"$/.test(t)) return JSON.parse(t.replace(/\\'/g, "'"));
|
|
106
|
+
if (/^'.*'$/.test(t)) return t.slice(1, -1);
|
|
107
|
+
if (/^-?\d+$/.test(t)) return Number(t);
|
|
108
|
+
if (/^-?\d+\.\d+$/.test(t)) return Number(t);
|
|
109
|
+
if (t.startsWith("[")) return parseArray(t, path, line);
|
|
110
|
+
if (t.startsWith("{")) return parseInlineTable(t, path, line);
|
|
111
|
+
if (t === "") return "";
|
|
112
|
+
return t;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function parseArray(t, path, line) {
|
|
116
|
+
const inner = t.slice(1, -1).trim();
|
|
117
|
+
if (!inner) return [];
|
|
118
|
+
const out = [];
|
|
119
|
+
let buf = "";
|
|
120
|
+
let q = null;
|
|
121
|
+
let depth = 0;
|
|
122
|
+
for (let i = 0; i < inner.length; i++) {
|
|
123
|
+
const c = inner[i];
|
|
124
|
+
if (q) { buf += c; if (c === "\\") { buf += inner[++i] || ""; continue; } if (c === q) q = null; continue; }
|
|
125
|
+
if (c === '"' || c === "'") { q = c; buf += c; continue; }
|
|
126
|
+
if (c === "[" || c === "{") { depth++; buf += c; continue; }
|
|
127
|
+
if (c === "]" || c === "}") { depth--; buf += c; continue; }
|
|
128
|
+
if (c === "," && depth === 0) { out.push(value(buf.trim(), path, line)); buf = ""; continue; }
|
|
129
|
+
buf += c;
|
|
130
|
+
}
|
|
131
|
+
if (buf.trim()) out.push(value(buf.trim(), path, line));
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function parseInlineTable(t, path, line) {
|
|
136
|
+
const inner = t.slice(1, -1).trim();
|
|
137
|
+
const out = {};
|
|
138
|
+
if (!inner) return out;
|
|
139
|
+
for (const part of splitTop(inner)) {
|
|
140
|
+
const eq = findEquals(part);
|
|
141
|
+
if (eq < 0) continue;
|
|
142
|
+
out[unquoteKey(part.slice(0, eq).trim())] = value(part.slice(eq + 1).trim(), path, line);
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function splitTop(s) {
|
|
148
|
+
const out = [];
|
|
149
|
+
let buf = "";
|
|
150
|
+
let q = null;
|
|
151
|
+
let depth = 0;
|
|
152
|
+
for (let i = 0; i < s.length; i++) {
|
|
153
|
+
const c = s[i];
|
|
154
|
+
if (q) { buf += c; if (c === "\\") { buf += s[++i] || ""; continue; } if (c === q) q = null; continue; }
|
|
155
|
+
if (c === '"' || c === "'") { q = c; buf += c; continue; }
|
|
156
|
+
if (c === "[" || c === "{") { depth++; buf += c; continue; }
|
|
157
|
+
if (c === "]" || c === "}") { depth--; buf += c; continue; }
|
|
158
|
+
if (c === "," && depth === 0) { out.push(buf.trim()); buf = ""; continue; }
|
|
159
|
+
buf += c;
|
|
160
|
+
}
|
|
161
|
+
if (buf.trim()) out.push(buf.trim());
|
|
162
|
+
return out;
|
|
163
|
+
}
|